mirror of
https://github.com/bitwarden/server.git
synced 2025-06-24 12:48:48 -05:00
Merge branch 'main' into dirt/pm-20574/database_tables_and_scripts_riskinsights
This commit is contained in:
commit
5546a3874d
28
.github/workflows/build.yml
vendored
28
.github/workflows/build.yml
vendored
@ -350,14 +350,6 @@ jobs:
|
||||
cd docker-stub/US; zip -r ../../docker-stub-US.zip *; cd ../..
|
||||
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
|
||||
|
||||
- name: Make Docker stub checksums
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
run: |
|
||||
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
|
||||
sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt
|
||||
|
||||
- name: Upload Docker stub US artifact
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
@ -378,26 +370,6 @@ jobs:
|
||||
path: docker-stub-EU.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Docker stub US checksum artifact
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: docker-stub-US-sha256.txt
|
||||
path: docker-stub-US-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Docker stub EU checksum artifact
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: docker-stub-EU-sha256.txt
|
||||
path: docker-stub-EU-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Build Public API Swagger
|
||||
run: |
|
||||
cd ./src/Api
|
||||
|
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@ -17,6 +17,9 @@ on:
|
||||
env:
|
||||
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
name: Setup
|
||||
@ -65,9 +68,7 @@ jobs:
|
||||
workflow_conclusion: success
|
||||
branch: ${{ needs.setup.outputs.branch-name }}
|
||||
artifacts: "docker-stub-US.zip,
|
||||
docker-stub-US-sha256.txt,
|
||||
docker-stub-EU.zip,
|
||||
docker-stub-EU-sha256.txt,
|
||||
swagger.json"
|
||||
|
||||
- name: Dry Run - Download latest release Docker stubs
|
||||
@ -78,9 +79,7 @@ jobs:
|
||||
workflow_conclusion: success
|
||||
branch: main
|
||||
artifacts: "docker-stub-US.zip,
|
||||
docker-stub-US-sha256.txt,
|
||||
docker-stub-EU.zip,
|
||||
docker-stub-EU-sha256.txt,
|
||||
swagger.json"
|
||||
|
||||
- name: Create release
|
||||
@ -88,9 +87,7 @@ jobs:
|
||||
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
|
||||
with:
|
||||
artifacts: "docker-stub-US.zip,
|
||||
docker-stub-US-sha256.txt,
|
||||
docker-stub-EU.zip,
|
||||
docker-stub-EU-sha256.txt,
|
||||
swagger.json"
|
||||
commit: ${{ github.sha }}
|
||||
tag: "v${{ needs.setup.outputs.release_version }}"
|
||||
|
@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2025.6.0</Version>
|
||||
<Version>2025.6.1</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -38,4 +40,8 @@ public class CollectionResponseModel : CollectionBaseModel, IResponseModel
|
||||
/// The associated groups that this collection is assigned to.
|
||||
/// </summary>
|
||||
public IEnumerable<AssociationWithPermissionsResponseModel> Groups { get; set; }
|
||||
/// <summary>
|
||||
/// The type of this collection
|
||||
/// </summary>
|
||||
public CollectionType Type { get; set; }
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Interfaces;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models;
|
||||
@ -9,23 +10,75 @@ using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// An association table between one <see cref="User"/> and one <see cref="Organization"/>, representing that user's
|
||||
/// membership in the organization. "Member" refers to the OrganizationUser object.
|
||||
/// </summary>
|
||||
public class OrganizationUser : ITableObject<Guid>, IExternal, IOrganizationUser
|
||||
{
|
||||
/// <summary>
|
||||
/// A unique random identifier.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
/// <summary>
|
||||
/// The ID of the Organization that the user is a member of.
|
||||
/// </summary>
|
||||
public Guid OrganizationId { get; set; }
|
||||
/// <summary>
|
||||
/// The ID of the User that is the member. This is NULL if the Status is Invited (or Invited and then Revoked), because
|
||||
/// it is not linked to a specific User yet.
|
||||
/// </summary>
|
||||
public Guid? UserId { get; set; }
|
||||
/// <summary>
|
||||
/// The email address of the user invited to the organization. This is NULL if the Status is not Invited (or
|
||||
/// Invited and then Revoked), because in that case the OrganizationUser is linked to a User
|
||||
/// and the email is stored on the User object.
|
||||
/// </summary>
|
||||
[MaxLength(256)]
|
||||
public string? Email { get; set; }
|
||||
/// <summary>
|
||||
/// The Organization symmetric key encrypted with the User's public key. NULL if the user is not in a Confirmed
|
||||
/// (or Confirmed and then Revoked) status.
|
||||
/// </summary>
|
||||
public string? Key { get; set; }
|
||||
/// <summary>
|
||||
/// The User's symmetric key encrypted with the Organization's public key. NULL if the OrganizationUser
|
||||
/// is not enrolled in account recovery.
|
||||
/// </summary>
|
||||
public string? ResetPasswordKey { get; set; }
|
||||
/// <inheritdoc cref="OrganizationUserStatusType"/>
|
||||
public OrganizationUserStatusType Status { get; set; }
|
||||
/// <summary>
|
||||
/// The User's role in the Organization.
|
||||
/// </summary>
|
||||
public OrganizationUserType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// An ID used to identify the OrganizationUser with an external directory service. Used by Directory Connector
|
||||
/// and SCIM.
|
||||
/// </summary>
|
||||
[MaxLength(300)]
|
||||
public string? ExternalId { get; set; }
|
||||
/// <summary>
|
||||
/// The date the OrganizationUser was created, i.e. when the User was first invited to the Organization.
|
||||
/// </summary>
|
||||
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||
/// <summary>
|
||||
/// The last date the OrganizationUser entry was updated.
|
||||
/// </summary>
|
||||
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
|
||||
/// <summary>
|
||||
/// A json blob representing the <see cref="Bit.Core.Models.Data.Permissions"/> of the OrganizationUser if they
|
||||
/// are a Custom user role (i.e. the <see cref="OrganizationUserType"/> is Custom). MAY be NULL if they are not
|
||||
/// a custom user, but this is not guaranteed; do not use this to determine their role.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Avoid using this property directly - instead use the <see cref="GetPermissions"/> and <see cref="SetPermissions"/>
|
||||
/// helper methods.
|
||||
/// </remarks>
|
||||
public string? Permissions { get; set; }
|
||||
/// <summary>
|
||||
/// True if the User has access to Secrets Manager for this Organization, false otherwise.
|
||||
/// </summary>
|
||||
public bool AccessSecretsManager { get; set; }
|
||||
|
||||
public void SetNewId()
|
||||
|
@ -1,9 +1,34 @@
|
||||
namespace Bit.Core.Enums;
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Core.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the different stages of a member's lifecycle in an organization.
|
||||
/// The <see cref="OrganizationUser"/> object is populated differently depending on their Status.
|
||||
/// </summary>
|
||||
public enum OrganizationUserStatusType : short
|
||||
{
|
||||
/// <summary>
|
||||
/// The OrganizationUser entry only represents an invitation to join the organization. It is not linked to a
|
||||
/// specific User yet.
|
||||
/// </summary>
|
||||
Invited = 0,
|
||||
/// <summary>
|
||||
/// The User has accepted the invitation and linked their User account to the OrganizationUser entry.
|
||||
/// </summary>
|
||||
Accepted = 1,
|
||||
/// <summary>
|
||||
/// An administrator has granted the User access to the organization. This is the final step in the User becoming
|
||||
/// a "full" member of the organization, including a key exchange so that they can decrypt organization data.
|
||||
/// </summary>
|
||||
Confirmed = 2,
|
||||
/// <summary>
|
||||
/// The OrganizationUser has been revoked from the organization and cannot access organization data while in this state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An OrganizationUser may move into this status from any other status, and will move back to their original status
|
||||
/// if restored. This allows an administrator to easily suspend and restore access without going through the
|
||||
/// Invite flow again.
|
||||
/// </remarks>
|
||||
Revoked = -1,
|
||||
}
|
||||
|
@ -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)
|
||||
});
|
||||
|
||||
|
@ -2,6 +2,7 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Utilities.Validation;
|
||||
@ -83,14 +84,9 @@ public class InviteUsersPasswordManagerValidator(
|
||||
return invalidEnvironment.Map(request);
|
||||
}
|
||||
|
||||
var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization);
|
||||
|
||||
if (organizationValidationResult is Invalid<InviteOrganization> organizationValidation)
|
||||
{
|
||||
return organizationValidation.Map(request);
|
||||
}
|
||||
|
||||
// Organizations managed by a provider need to be scaled by the provider. This needs to be checked in the event seats are increasing.
|
||||
var provider = await providerRepository.GetByOrganizationIdAsync(request.InviteOrganization.OrganizationId);
|
||||
|
||||
if (provider is not null)
|
||||
{
|
||||
var providerValidationResult = InvitingUserOrganizationProviderValidator.Validate(new InviteOrganizationProvider(provider));
|
||||
@ -101,6 +97,13 @@ public class InviteUsersPasswordManagerValidator(
|
||||
}
|
||||
}
|
||||
|
||||
var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization);
|
||||
|
||||
if (organizationValidationResult is Invalid<InviteOrganization> organizationValidation)
|
||||
{
|
||||
return organizationValidation.Map(request);
|
||||
}
|
||||
|
||||
var paymentSubscription = await paymentService.GetSubscriptionAsync(
|
||||
await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId));
|
||||
|
||||
|
@ -1,10 +1,9 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||
using Bit.Core.AdminConsole.Utilities.Validation;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||
|
||||
public static class InviteUserPaymentValidation
|
||||
{
|
||||
|
@ -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);
|
||||
|
||||
|
@ -104,8 +104,8 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator
|
||||
throw new BadRequestException(string.Join(", ", commandResult.ErrorMessages));
|
||||
}
|
||||
|
||||
await Task.WhenAll(currentActiveRevocableOrganizationUsers.Select(x =>
|
||||
_mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), x.Email)));
|
||||
await Task.WhenAll(nonCompliantUsers.Select(nonCompliantUser =>
|
||||
_mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), nonCompliantUser.user.Email)));
|
||||
}
|
||||
|
||||
private static bool MembersWithNoMasterPasswordWillLoseAccess(
|
||||
|
@ -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<Organization, Guid>
|
||||
Task<ICollection<Organization>> GetByVerifiedUserEmailDomainAsync(Guid userId);
|
||||
Task<ICollection<Organization>> GetAddableToProviderByUserIdAsync(Guid userId, ProviderType providerType);
|
||||
Task<ICollection<Organization>> GetManyByIdsAsync(IEnumerable<Guid> ids);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The ID of the organization to get the occupied seat count for.</param>
|
||||
/// <returns>The number of occupied seats for the organization.</returns>
|
||||
Task<OrganizationSeatCounts> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId);
|
||||
}
|
||||
|
@ -18,16 +18,6 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
|
||||
Task<ICollection<OrganizationUser>> GetManyByUserAsync(Guid userId);
|
||||
Task<ICollection<OrganizationUser>> GetManyByOrganizationAsync(Guid organizationId, OrganizationUserType? type);
|
||||
Task<int> GetCountByOrganizationAsync(Guid organizationId, string email, bool onlyRegisteredUsers);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The ID of the organization to get the occupied seat count for.</param>
|
||||
/// <returns>The number of occupied seats for the organization.</returns>
|
||||
Task<int> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId);
|
||||
Task<ICollection<string>> SelectKnownEmailsAsync(Guid organizationId, IEnumerable<string> emails, bool onlyRegisteredUsers);
|
||||
Task<OrganizationUser?> GetByOrganizationAsync(Guid organizationId, Guid userId);
|
||||
Task<Tuple<OrganizationUser?, ICollection<CollectionAccessSelection>>> GetByIdWithCollectionsAsync(Guid id);
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -31,7 +31,6 @@ public class OrganizationBillingService(
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<OrganizationBillingService> 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
|
||||
|
@ -181,6 +181,8 @@ public static class FeatureFlagKeys
|
||||
public const string EnablePMFlightRecorder = "enable-pm-flight-recorder";
|
||||
public const string MobileErrorReporting = "mobile-error-reporting";
|
||||
public const string AndroidChromeAutofill = "android-chrome-autofill";
|
||||
public const string EnablePMPreloginSettings = "enable-pm-prelogin-settings";
|
||||
public const string AppIntents = "app-intents";
|
||||
|
||||
/* Platform Team */
|
||||
public const string PersistPopupView = "persist-popup-view";
|
||||
|
@ -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;
|
||||
}
|
@ -16,7 +16,7 @@ public class CreateSponsorshipCommand(
|
||||
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
|
||||
IUserService userService,
|
||||
IOrganizationService organizationService,
|
||||
IOrganizationUserRepository organizationUserRepository) : ICreateSponsorshipCommand
|
||||
IOrganizationRepository organizationRepository) : ICreateSponsorshipCommand
|
||||
{
|
||||
public async Task<OrganizationSponsorship> 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)
|
||||
{
|
||||
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -12,7 +12,6 @@ using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Models;
|
||||
@ -29,12 +28,9 @@ using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Repositories;
|
||||
using Fido2NetLib;
|
||||
using Fido2NetLib.Objects;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@ -44,12 +40,11 @@ using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
public class UserService : UserManager<User>, IUserService
|
||||
{
|
||||
private const string PremiumPlanId = "premium-annually";
|
||||
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly ICipherRepository _cipherRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationDomainRepository _organizationDomainRepository;
|
||||
@ -65,17 +60,14 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IPolicyService _policyService;
|
||||
private readonly IDataProtector _organizationServiceDataProtector;
|
||||
private readonly IFido2 _fido2;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
|
||||
private readonly IProviderUserRepository _providerUserRepository;
|
||||
private readonly IStripeSyncService _stripeSyncService;
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IPremiumUserBillingService _premiumUserBillingService;
|
||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||
private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IDistributedCache _distributedCache;
|
||||
@ -83,7 +75,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
|
||||
public UserService(
|
||||
IUserRepository userRepository,
|
||||
ICipherRepository cipherRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationDomainRepository organizationDomainRepository,
|
||||
@ -101,7 +92,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
ILicensingService licenseService,
|
||||
IEventService eventService,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
IPaymentService paymentService,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyService policyService,
|
||||
@ -111,10 +101,8 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
IAcceptOrgUserCommand acceptOrgUserCommand,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
IStripeSyncService stripeSyncService,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
IFeatureService featureService,
|
||||
IPremiumUserBillingService premiumUserBillingService,
|
||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||
IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IDistributedCache distributedCache,
|
||||
@ -131,7 +119,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
logger)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_cipherRepository = cipherRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationDomainRepository = organizationDomainRepository;
|
||||
@ -147,18 +134,14 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
_paymentService = paymentService;
|
||||
_policyRepository = policyRepository;
|
||||
_policyService = policyService;
|
||||
_organizationServiceDataProtector = dataProtectionProvider.CreateProtector(
|
||||
"OrganizationServiceDataProtector");
|
||||
_fido2 = fido2;
|
||||
_currentContext = currentContext;
|
||||
_globalSettings = globalSettings;
|
||||
_acceptOrgUserCommand = acceptOrgUserCommand;
|
||||
_providerUserRepository = providerUserRepository;
|
||||
_stripeSyncService = stripeSyncService;
|
||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||
_featureService = featureService;
|
||||
_premiumUserBillingService = premiumUserBillingService;
|
||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||
_revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
_distributedCache = distributedCache;
|
||||
|
@ -8,6 +8,16 @@ namespace Bit.Icons.Controllers;
|
||||
[Route("")]
|
||||
public class IconsController : Controller
|
||||
{
|
||||
// Basic bwi-globe icon
|
||||
private static readonly byte[] _notFoundImage = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUg" +
|
||||
"AAABMAAAATCAQAAADYWf5HAAABu0lEQVR42nXSvWuTURTH8R+t0heI9Y04aJycdBLNJNrBFBU7OFgUER3q21I0bXK+JwZ" +
|
||||
"pXISm/QdcRB3EgqBBsNihsUbbgODQQSKCuKSDOApJuuhj8tCYQj/jvYfD795z1MZ+nBKrNKhSwrMxbZTrtRnqlEjZkB/x" +
|
||||
"C/xmhZrlc71qS0Up8yVzTCGucFNKD1JhORVd70SZNU4okNx5d4+U2UXRIpJFWLClsR79YzN88wQvLWNzzPKEeS/wkQGpW" +
|
||||
"VhhqhW8TtDJD3Mm1x/23zLSrZCdpBY8BueTNjHSbc+8wC9HlHgU5Aj5AW5zPdcVdpq0UcknWBSr/pjixO4gfp899Kd23p" +
|
||||
"M2qQCH7LkCnqAqGh73OK/8NPOcaibr90LrW/yWAnaUhqjaOSl9nFR2r5rsqo22ypn1B5IN8VOUMHVgOnNQIX+d62plcz6" +
|
||||
"rg1/jskK8CMb4we4pG6OWHtR/LBJkC2E4a7ZPkuX5ntumAOM2xxveclEhLvGH6XCmLPs735Eetrw63NnOgr9P9q1viC3x" +
|
||||
"lRUGOjImqFDuOBvrYYoaZU9z1uPpYae5NfdvbNVG2ZjDIlXq/oMi46lo++4vjjPBl2Dlg00AAAAASUVORK5CYII=");
|
||||
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly IDomainMappingService _domainMappingService;
|
||||
private readonly IIconFetchingService _iconFetchingService;
|
||||
@ -89,7 +99,7 @@ public class IconsController : Controller
|
||||
|
||||
if (icon == null)
|
||||
{
|
||||
return new NotFoundResult();
|
||||
return new FileContentResult(_notFoundImage, "image/png");
|
||||
}
|
||||
|
||||
return new FileContentResult(icon.Image, icon.Format);
|
||||
|
@ -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<Organization, Guid>, IOrganizat
|
||||
public async Task<ICollection<Organization>> GetManyByIdsAsync(IEnumerable<Guid> ids)
|
||||
{
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
|
||||
return (await connection.QueryAsync<Organization>(
|
||||
$"[{Schema}].[{Table}_ReadManyByIds]",
|
||||
new { OrganizationIds = ids.ToGuidIdArrayTVP() },
|
||||
commandType: CommandType.StoredProcedure))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<OrganizationSeatCounts> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var result = await connection.QueryAsync<OrganizationSeatCounts>(
|
||||
"[dbo].[Organization_ReadOccupiedSeatCountByOrganizationId]",
|
||||
new { OrganizationId = organizationId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return result.SingleOrDefault() ?? new OrganizationSeatCounts();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -88,19 +88,6 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var result = await connection.ExecuteScalarAsync<int>(
|
||||
"[dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId]",
|
||||
new { OrganizationId = organizationId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
|
@ -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<Core.AdminConsole.Entities.Orga
|
||||
{
|
||||
throw new NotImplementedException("Collection enhancements migration is not yet supported for Entity Framework.");
|
||||
}
|
||||
|
||||
public async Task<OrganizationSeatCounts> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -228,12 +228,6 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
|
||||
return await GetCountFromQuery(query);
|
||||
}
|
||||
|
||||
public async Task<int> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId)
|
||||
{
|
||||
var query = new OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery(organizationId);
|
||||
return await GetCountFromQuery(query);
|
||||
}
|
||||
|
||||
public async Task<int> GetCountByOrganizationIdAsync(Guid organizationId)
|
||||
{
|
||||
var query = new OrganizationUserReadCountByOrganizationIdQuery(organizationId);
|
||||
|
@ -1,48 +0,0 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Repositories.Queries;
|
||||
|
||||
public class OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery : IQuery<OrganizationUser>
|
||||
{
|
||||
private readonly Guid _organizationId;
|
||||
|
||||
public OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery(Guid organizationId)
|
||||
{
|
||||
_organizationId = organizationId;
|
||||
}
|
||||
|
||||
public IQueryable<OrganizationUser> 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);
|
||||
}
|
||||
}
|
@ -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
|
@ -4,8 +4,19 @@ using AutoFixture.Kernel;
|
||||
|
||||
namespace Bit.Test.Common.AutoFixture;
|
||||
|
||||
/// <summary>
|
||||
/// A utility class that encapsulates a system under test (sut) and its dependencies.
|
||||
/// By default, all dependencies are initialized as mocks using the NSubstitute library.
|
||||
/// SutProvider provides an interface for accessing these dependencies in the arrange and assert stages of your tests.
|
||||
/// </summary>
|
||||
/// <typeparam name="TSut">The concrete implementation of the class being tested.</typeparam>
|
||||
public class SutProvider<TSut> : ISutProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// A record of the configured dependencies (constructor parameters). The outer Dictionary is keyed by the dependency's
|
||||
/// type, and the inner dictionary is keyed by the parameter name (optionally used to disambiguate parameters with the same type).
|
||||
/// The inner dictionary value is the dependency.
|
||||
/// </summary>
|
||||
private Dictionary<Type, Dictionary<string, object>> _dependencies;
|
||||
private readonly IFixture _fixture;
|
||||
private readonly ConstructorParameterRelay<TSut> _constructorParameterRelay;
|
||||
@ -23,9 +34,21 @@ public class SutProvider<TSut> : ISutProvider
|
||||
_fixture.Customizations.Add(_constructorParameterRelay);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a dependency to be injected when the sut is created. You must call <see cref="Create"/> after
|
||||
/// this method to (re)create the sut with the dependency.
|
||||
/// </summary>
|
||||
/// <param name="dependency">The dependency to register.</param>
|
||||
/// <param name="parameterName">An optional parameter name to disambiguate the dependency if there are multiple of the same type. You generally don't need this.</param>
|
||||
/// <typeparam name="T">The type to register the dependency under - usually an interface. This should match the type expected by the sut's constructor.</typeparam>
|
||||
/// <returns></returns>
|
||||
public SutProvider<TSut> SetDependency<T>(T dependency, string parameterName = "")
|
||||
=> SetDependency(typeof(T), dependency, parameterName);
|
||||
public SutProvider<TSut> SetDependency(Type dependencyType, object dependency, string parameterName = "")
|
||||
|
||||
/// <summary>
|
||||
/// An overload for <see cref="SetDependency{T}"/> which takes a runtime <see cref="Type"/> object rather than a compile-time type.
|
||||
/// </summary>
|
||||
private SutProvider<TSut> SetDependency(Type dependencyType, object dependency, string parameterName = "")
|
||||
{
|
||||
if (_dependencies.TryGetValue(dependencyType, out var dependencyForType))
|
||||
{
|
||||
@ -39,45 +62,69 @@ public class SutProvider<TSut> : ISutProvider
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a dependency of the sut. Can only be called after the dependency has been set, either explicitly with
|
||||
/// <see cref="SetDependency{T}"/> or automatically with <see cref="Create"/>.
|
||||
/// As dependencies are initialized with NSubstitute mocks by default, this is often used to retrieve those mocks in order to
|
||||
/// configure them during the arrange stage, or check received calls in the assert stage.
|
||||
/// </summary>
|
||||
/// <param name="parameterName">An optional parameter name to disambiguate the dependency if there are multiple of the same type. You generally don't need this.</param>
|
||||
/// <typeparam name="T">The type of the dependency you want to get - usually an interface.</typeparam>
|
||||
/// <returns>The dependency.</returns>
|
||||
public T GetDependency<T>(string parameterName = "") => (T)GetDependency(typeof(T), parameterName);
|
||||
public object GetDependency(Type dependencyType, string parameterName = "")
|
||||
|
||||
/// <summary>
|
||||
/// An overload for <see cref="GetDependency{T}"/> which takes a runtime <see cref="Type"/> object rather than a compile-time type.
|
||||
/// </summary>
|
||||
private object GetDependency(Type dependencyType, string parameterName = "")
|
||||
{
|
||||
if (DependencyIsSet(dependencyType, parameterName))
|
||||
{
|
||||
return _dependencies[dependencyType][parameterName];
|
||||
}
|
||||
else if (_dependencies.TryGetValue(dependencyType, out var knownDependencies))
|
||||
|
||||
if (_dependencies.TryGetValue(dependencyType, out var knownDependencies))
|
||||
{
|
||||
if (knownDependencies.Values.Count == 1)
|
||||
{
|
||||
return knownDependencies.Values.Single();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentException(string.Concat($"Dependency of type {dependencyType.Name} and name ",
|
||||
$"{parameterName} does not exist. Available dependency names are: ",
|
||||
string.Join(", ", knownDependencies.Keys)));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentException($"Dependency of type {dependencyType.Name} and name {parameterName} has not been set.");
|
||||
|
||||
throw new ArgumentException(string.Concat($"Dependency of type {dependencyType.Name} and name ",
|
||||
$"{parameterName} does not exist. Available dependency names are: ",
|
||||
string.Join(", ", knownDependencies.Keys)));
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Dependency of type {dependencyType.Name} and name {parameterName} has not been set.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all the dependencies and the sut. This reverts the SutProvider back to a fully uninitialized state.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_dependencies = new Dictionary<Type, Dictionary<string, object>>();
|
||||
Sut = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recreate a new sut with all new dependencies. This will reset all dependencies, including mocked return values
|
||||
/// and any dependencies set with <see cref="SetDependency{T}"/>.
|
||||
/// </summary>
|
||||
public void Recreate()
|
||||
{
|
||||
_dependencies = new Dictionary<Type, Dictionary<string, object>>();
|
||||
Sut = _fixture.Create<TSut>();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="Create()"/>>
|
||||
ISutProvider ISutProvider.Create() => Create();
|
||||
|
||||
/// <summary>
|
||||
/// Creates the sut, injecting any dependencies configured via <see cref="SetDependency{T}"/> and falling back to
|
||||
/// NSubstitute mocks for any dependencies that have not been explicitly configured.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public SutProvider<TSut> Create()
|
||||
{
|
||||
Sut = _fixture.Create<TSut>();
|
||||
@ -89,6 +136,19 @@ public class SutProvider<TSut> : ISutProvider
|
||||
|
||||
private object GetDefault(Type type) => type.IsValueType ? Activator.CreateInstance(type) : null;
|
||||
|
||||
/// <summary>
|
||||
/// A specimen builder which tells Autofixture to use the dependency registered in <see cref="SutProvider{T}"/>
|
||||
/// when creating test data. If no matching dependency exists in <see cref="SutProvider{TSut}"/>, it creates
|
||||
/// an NSubstitute mock and registers it using <see cref="SutProvider{TSut}.SetDependency{T}"/>
|
||||
/// so it can be retrieved later.
|
||||
/// This is the link between <see cref="SutProvider{T}"/> and Autofixture.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Autofixture knows how to create sample data of simple types (such as an int or string) but not more complex classes.
|
||||
/// We create our own <see cref="ISpecimenBuilder"/> and register it with the <see cref="Fixture"/> in
|
||||
/// <see cref="SutProvider{TSut}"/> to provide that instruction.
|
||||
/// </remarks>
|
||||
/// <typeparam name="T">The type of the sut.</typeparam>
|
||||
private class ConstructorParameterRelay<T> : ISpecimenBuilder
|
||||
{
|
||||
private readonly SutProvider<T> _sutProvider;
|
||||
@ -102,6 +162,7 @@ public class SutProvider<TSut> : ISutProvider
|
||||
|
||||
public object Create(object request, ISpecimenContext context)
|
||||
{
|
||||
// Basic checks to filter out irrelevant requests from Autofixture
|
||||
if (context == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
@ -116,16 +177,22 @@ public class SutProvider<TSut> : ISutProvider
|
||||
return new NoSpecimen();
|
||||
}
|
||||
|
||||
// Use the dependency set under this parameter name, if any
|
||||
if (_sutProvider.DependencyIsSet(parameterInfo.ParameterType, parameterInfo.Name))
|
||||
{
|
||||
return _sutProvider.GetDependency(parameterInfo.ParameterType, parameterInfo.Name);
|
||||
}
|
||||
// Return default type if set
|
||||
else if (_sutProvider.DependencyIsSet(parameterInfo.ParameterType, ""))
|
||||
|
||||
// Use the default dependency set for this type, if any (i.e. no parameter name has been specified)
|
||||
if (_sutProvider.DependencyIsSet(parameterInfo.ParameterType, ""))
|
||||
{
|
||||
return _sutProvider.GetDependency(parameterInfo.ParameterType, "");
|
||||
}
|
||||
|
||||
// Fallback: pass the request down the chain. This lets another fixture customization populate the value.
|
||||
// If you haven't added any customizations, this should be an NSubstitute mock.
|
||||
// It is registered with SetDependency so you can retrieve it later.
|
||||
|
||||
// This is the equivalent of _fixture.Create<parameterInfo.ParameterType>, but no overload for
|
||||
// Create(Type type) exists.
|
||||
var dependency = new SpecimenContext(_fixture).Resolve(new SeededRequest(parameterInfo.ParameterType,
|
||||
|
@ -137,6 +137,14 @@ public class InviteOrganizationUserCommandTests
|
||||
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
|
||||
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)));
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
|
||||
|
||||
@ -202,6 +210,14 @@ public class InviteOrganizationUserCommandTests
|
||||
.Returns(new Invalid<InviteOrganizationUsersValidationRequest>(
|
||||
new Error<InviteOrganizationUsersValidationRequest>(errorMessage, validationRequest)));
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
|
||||
|
||||
@ -272,6 +288,14 @@ public class InviteOrganizationUserCommandTests
|
||||
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)
|
||||
.WithPasswordManagerUpdate(new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1))));
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.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<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
|
||||
|
||||
@ -413,6 +445,14 @@ public class InviteOrganizationUserCommandTests
|
||||
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)
|
||||
.WithPasswordManagerUpdate(passwordManagerUpdate)));
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.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<IOrganizationUserRepository>();
|
||||
var orgRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
|
||||
orgUserRepository
|
||||
.SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any<IEnumerable<string>>(), 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<IOrganizationRepository>();
|
||||
|
||||
orgRepository.GetByIdAsync(organization.Id)
|
||||
.Returns(organization);
|
||||
|
||||
@ -566,6 +609,14 @@ public class InviteOrganizationUserCommandTests
|
||||
.SendInvitesAsync(Arg.Any<SendInvitesRequest>())
|
||||
.Throws(new Exception("Something went wrong"));
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
|
||||
|
||||
@ -671,6 +722,14 @@ public class InviteOrganizationUserCommandTests
|
||||
}
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
|
||||
|
||||
@ -762,6 +821,14 @@ public class InviteOrganizationUserCommandTests
|
||||
}
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.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<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.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<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(0);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Business;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||
using Bit.Core.AdminConsole.Utilities.Validation;
|
||||
|
@ -31,7 +31,12 @@ public class RestoreOrganizationUserCommandTests
|
||||
[OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider<RestoreOrganizationUserCommand> sutProvider)
|
||||
{
|
||||
RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
@ -49,7 +54,12 @@ public class RestoreOrganizationUserCommandTests
|
||||
public async Task RestoreUser_WithEventSystemUser_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, EventSystemUser eventSystemUser, SutProvider<RestoreOrganizationUserCommand> sutProvider)
|
||||
{
|
||||
RestoreUser_Setup(organization, null, organizationUser, sutProvider);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
await sutProvider.Sut.RestoreUserAsync(organizationUser, eventSystemUser);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
@ -151,7 +161,12 @@ public class RestoreOrganizationUserCommandTests
|
||||
sutProvider.GetDependency<IPolicyService>()
|
||||
.AnyPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any<OrganizationUserStatusType>())
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
var user = new User();
|
||||
user.Email = "test@bitwarden.com";
|
||||
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(organizationUser.UserId.Value).Returns(user);
|
||||
@ -184,7 +199,12 @@ public class RestoreOrganizationUserCommandTests
|
||||
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
|
||||
.TwoFactorIsEnabledAsync(Arg.Is<IEnumerable<Guid>>(i => i.Contains(organizationUser.UserId.Value)))
|
||||
.Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, false) });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
|
||||
|
||||
sutProvider.GetDependency<IPolicyService>()
|
||||
@ -219,7 +239,12 @@ public class RestoreOrganizationUserCommandTests
|
||||
SutProvider<RestoreOrganizationUserCommand> sutProvider)
|
||||
{
|
||||
organizationUser.Email = null;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PolicyRequirements)
|
||||
.Returns(true);
|
||||
@ -278,7 +303,12 @@ public class RestoreOrganizationUserCommandTests
|
||||
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
|
||||
.TwoFactorIsEnabledAsync(Arg.Is<IEnumerable<Guid>>(i => i.Contains(organizationUser.UserId.Value)))
|
||||
.Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, true) });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
@ -344,6 +374,15 @@ public class RestoreOrganizationUserCommandTests
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByUserAsync(organizationUser.UserId.Value)
|
||||
.Returns(new[] { organizationUser, secondOrganizationUser });
|
||||
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
|
||||
.TwoFactorIsEnabledAsync(Arg.Is<IEnumerable<Guid>>(i => i.Contains(organizationUser.UserId.Value)))
|
||||
.Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) });
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
sutProvider.GetDependency<IPolicyService>()
|
||||
.GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any<OrganizationUserStatusType>())
|
||||
.Returns(new[]
|
||||
@ -392,7 +431,12 @@ public class RestoreOrganizationUserCommandTests
|
||||
{
|
||||
new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.SingleOrg, OrganizationUserStatus = OrganizationUserStatusType.Revoked }
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
sutProvider.GetDependency<IPolicyService>()
|
||||
.GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any<OrganizationUserStatusType>())
|
||||
.Returns([
|
||||
@ -455,7 +499,12 @@ public class RestoreOrganizationUserCommandTests
|
||||
PolicyType = PolicyType.TwoFactorAuthentication
|
||||
}
|
||||
]));
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
var user = new User { Email = "test@bitwarden.com" };
|
||||
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(organizationUser.UserId.Value).Returns(user);
|
||||
|
||||
@ -475,6 +524,40 @@ public class RestoreOrganizationUserCommandTests
|
||||
.PushSyncOrgKeysAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[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<RestoreOrganizationUserCommand> 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<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
sutProvider.GetDependency<IPolicyService>()
|
||||
.GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any<OrganizationUserStatusType>())
|
||||
.Returns([new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication }
|
||||
]);
|
||||
|
||||
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
|
||||
.TwoFactorIsEnabledAsync(Arg.Is<IEnumerable<Guid>>(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<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed);
|
||||
await sutProvider.GetDependency<IEventService>()
|
||||
.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<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.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<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||
organizationUserRepository
|
||||
.GetManyByUserAsync(organizationUser.UserId.Value)
|
||||
@ -584,7 +677,12 @@ public class RestoreOrganizationUserCommandTests
|
||||
otherOrganization.PlanType = PlanType.Free;
|
||||
|
||||
RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||
organizationUserRepository
|
||||
.GetManyByUserAsync(organizationUser.UserId.Value)
|
||||
@ -636,7 +734,12 @@ public class RestoreOrganizationUserCommandTests
|
||||
organizationUserRepository
|
||||
.GetManyAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id)))
|
||||
.Returns([orgUser1, orgUser2]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
twoFactorIsEnabledQuery
|
||||
.TwoFactorIsEnabledAsync(Arg.Is<IEnumerable<Guid>>(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<IEnumerable<Guid>>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id) && ids.Contains(orgUser3.Id)))
|
||||
.Returns(new[] { orgUser1, orgUser2, orgUser3 });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.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<IEnumerable<Guid>>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id) && ids.Contains(orgUser3.Id)))
|
||||
.Returns([orgUser1, orgUser2, orgUser3]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
userRepository.GetByIdAsync(orgUser2.UserId!.Value).Returns(new User { Email = "test@example.com" });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
@ -882,7 +995,12 @@ public class RestoreOrganizationUserCommandTests
|
||||
organizationUserRepository
|
||||
.GetManyAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(orgUser1.Id)))
|
||||
.Returns([orgUser1]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
organizationUserRepository
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([orgUserFromOtherOrg]);
|
||||
@ -942,7 +1060,12 @@ public class RestoreOrganizationUserCommandTests
|
||||
organizationUserRepository
|
||||
.GetManyAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(orgUser1.Id)))
|
||||
.Returns([orgUser1]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
organizationUserRepository
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([orgUserFromOtherOrg]);
|
||||
@ -972,7 +1095,14 @@ public class RestoreOrganizationUserCommandTests
|
||||
}
|
||||
targetOrganizationUser.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
|
||||
organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(requestingOrganizationUser != null && requestingOrganizationUser.Type is OrganizationUserType.Owner);
|
||||
sutProvider.GetDependency<ICurrentContext>().ManageUsers(organization.Id).Returns(requestingOrganizationUser != null && (requestingOrganizationUser.Type is OrganizationUserType.Owner or OrganizationUserType.Admin));
|
||||
}
|
||||
|
@ -60,16 +60,19 @@ public class TwoFactorAuthenticationPolicyValidatorTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task OnSaveSideEffectsAsync_RevokesNonCompliantUsers(
|
||||
public async Task OnSaveSideEffectsAsync_RevokesOnlyNonCompliantUsers(
|
||||
Organization organization,
|
||||
[PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy,
|
||||
SutProvider<TwoFactorAuthenticationPolicyValidator> sutProvider)
|
||||
{
|
||||
policy.OrganizationId = organization.Id = policyUpdate.OrganizationId;
|
||||
// Arrange
|
||||
policy.OrganizationId = policyUpdate.OrganizationId;
|
||||
organization.Id = policyUpdate.OrganizationId;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
|
||||
var orgUserDetailUserWithout2Fa = new OrganizationUserUserDetails
|
||||
var nonCompliantUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
@ -80,30 +83,57 @@ public class TwoFactorAuthenticationPolicyValidatorTests
|
||||
HasMasterPassword = true
|
||||
};
|
||||
|
||||
var compliantUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
Type = OrganizationUserType.User,
|
||||
Email = "user4@test.com",
|
||||
Name = "TEST",
|
||||
UserId = Guid.NewGuid(),
|
||||
HasMasterPassword = true
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([orgUserDetailUserWithout2Fa]);
|
||||
.Returns([nonCompliantUser, compliantUser]);
|
||||
|
||||
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
|
||||
.TwoFactorIsEnabledAsync(Arg.Any<IEnumerable<OrganizationUserUserDetails>>())
|
||||
.Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>()
|
||||
{
|
||||
(orgUserDetailUserWithout2Fa, false)
|
||||
(nonCompliantUser, false),
|
||||
(compliantUser, true)
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
||||
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>())
|
||||
.Returns(new CommandResult());
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
||||
.Received(1)
|
||||
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>());
|
||||
|
||||
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
||||
.Received(1)
|
||||
.RevokeNonCompliantOrganizationUsersAsync(Arg.Is<RevokeOrganizationUsersRequest>(req =>
|
||||
req.OrganizationId == policyUpdate.OrganizationId &&
|
||||
req.OrganizationUsers.SequenceEqual(new[] { nonCompliantUser })
|
||||
));
|
||||
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(),
|
||||
"user3@test.com");
|
||||
nonCompliantUser.Email);
|
||||
|
||||
// Did not send out an email for compliantUser
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(0)
|
||||
.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(),
|
||||
compliantUser.Email);
|
||||
}
|
||||
}
|
||||
|
@ -288,7 +288,7 @@ public class SavePolicyCommandTests
|
||||
{
|
||||
return new SutProvider<SavePolicyCommand>()
|
||||
.WithFakeTimeProvider()
|
||||
.SetDependency(typeof(IEnumerable<IPolicyValidator>), policyValidators ?? [])
|
||||
.SetDependency(policyValidators ?? [])
|
||||
.Create();
|
||||
}
|
||||
|
||||
|
@ -60,7 +60,12 @@ public class OrganizationServiceTests
|
||||
existingUsers.First().Type = OrganizationUserType.Owner;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(org.Id).Returns(org);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(org.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
|
||||
|
||||
@ -117,7 +122,12 @@ public class OrganizationServiceTests
|
||||
ExternalId = reInvitedUser.Email,
|
||||
});
|
||||
var expectedNewUsersCount = newUsers.Count - 1;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(org.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(org.Id).Returns(org);
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyDetailsByOrganizationAsync(org.Id)
|
||||
.Returns(existingUsers);
|
||||
@ -190,7 +200,12 @@ public class OrganizationServiceTests
|
||||
sutProvider.Create();
|
||||
|
||||
invite.Emails = invite.Emails.Append(invite.Emails.First());
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
|
||||
sutProvider.GetDependency<ICurrentContext>().ManageUsers(organization.Id).Returns(true);
|
||||
@ -221,6 +236,12 @@ public class OrganizationServiceTests
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
|
||||
sutProvider.GetDependency<ICurrentContext>().ManageUsers(organization.Id).Returns(true);
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => 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<IHasConfirmedOwnersExceptQuery>()
|
||||
.HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns(true);
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
|
||||
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
|
||||
|
||||
@ -340,6 +367,13 @@ public class OrganizationServiceTests
|
||||
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||
var currentContext = sutProvider.GetDependency<ICurrentContext>();
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
|
||||
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
||||
.HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any<IEnumerable<Guid>>())
|
||||
@ -397,7 +431,12 @@ public class OrganizationServiceTests
|
||||
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
var currentContext = sutProvider.GetDependency<ICurrentContext>();
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.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<IHasConfirmedOwnersExceptQuery>()
|
||||
.HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.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<IOrganizationRepository>()
|
||||
.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<ISendOrganizationInvitesCommand>().Received(1)
|
||||
@ -538,7 +587,12 @@ public class OrganizationServiceTests
|
||||
|
||||
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
|
||||
SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => 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<IOrganizationUserRepository>();
|
||||
|
||||
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
||||
.HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns(true);
|
||||
@ -631,7 +690,12 @@ public class OrganizationServiceTests
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||
var currentContext = sutProvider.GetDependency<ICurrentContext>();
|
||||
@ -664,6 +728,13 @@ public class OrganizationServiceTests
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
InviteUserHelper_ArrangeValidPermissions(organization, savingUser, sutProvider);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.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<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
|
||||
// Assume we need to add seats for all invited SM users
|
||||
sutProvider.GetDependency<ICountNewSmSeatsRequiredQuery>()
|
||||
.CountNewSmSeatsRequiredAsync(organization.Id, invitedSmUsers).Returns(invitedSmUsers);
|
||||
@ -813,7 +891,12 @@ public class OrganizationServiceTests
|
||||
|
||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType)
|
||||
.Returns(StaticStore.GetPlan(organization.PlanType));
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
|
||||
var actual = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSubscription(organization.Id, seatAdjustment, null));
|
||||
|
@ -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<OrganizationBillingService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<IPricingClient>().ListPlans().Returns(StaticStore.Plans.ToList());
|
||||
|
||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType)
|
||||
.Returns(StaticStore.GetPlan(organization.PlanType));
|
||||
|
||||
var subscriberService = sutProvider.GetDependency<ISubscriberService>();
|
||||
|
||||
subscriberService
|
||||
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(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<CustomerGetOptions>(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<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Users = 1, Sponsored = 0 });
|
||||
|
||||
var metadata = await sutProvider.Sut.GetMetadata(organizationId);
|
||||
|
||||
Assert.True(metadata!.IsOnSecretsManagerStandalone);
|
||||
|
@ -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<ICurrentContext>().UserId.Returns(sponsoringOrgUser.UserId.Value);
|
||||
|
||||
// Setup for checking available seats
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.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<IOrganizationUserRepository>()
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.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<IOrganizationUserRepository>()
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
|
||||
.Returns(10);
|
||||
.Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 10
|
||||
});
|
||||
|
||||
// Setup for checking if can scale
|
||||
sutProvider.GetDependency<IOrganizationService>()
|
||||
@ -443,9 +456,13 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
|
||||
]);
|
||||
|
||||
// Setup for checking available seats - organization has no available seats
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.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.";
|
||||
|
@ -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<IPricingClient>().GetPlanOrThrow(upgrade.Plan).Returns(StaticStore.GetPlan(upgrade.Plan));
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade);
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1).ReplaceAndUpdateCacheAsync(organization);
|
||||
}
|
||||
@ -107,7 +114,12 @@ public class UpgradeOrganizationPlanCommandTests
|
||||
organizationUpgrade.Plan = planType;
|
||||
|
||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organizationUpgrade.Plan).Returns(StaticStore.GetPlan(organizationUpgrade.Plan));
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
await sutProvider.Sut.UpgradePlanAsync(organization.Id, organizationUpgrade);
|
||||
await sutProvider.GetDependency<IPaymentService>().Received(1).AdjustSubscription(
|
||||
organization,
|
||||
@ -141,7 +153,12 @@ public class UpgradeOrganizationPlanCommandTests
|
||||
upgrade.AdditionalSeats = 15;
|
||||
upgrade.AdditionalSmSeats = 10;
|
||||
upgrade.AdditionalServiceAccounts = 20;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
var result = await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1).ReplaceAndUpdateCacheAsync(
|
||||
@ -173,6 +190,12 @@ public class UpgradeOrganizationPlanCommandTests
|
||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType).Returns(StaticStore.GetPlan(organization.PlanType));
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(2);
|
||||
|
||||
@ -202,6 +225,12 @@ public class UpgradeOrganizationPlanCommandTests
|
||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType).Returns(StaticStore.GetPlan(organization.PlanType));
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(1);
|
||||
sutProvider.GetDependency<IServiceAccountRepository>()
|
||||
|
@ -7,13 +7,10 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -21,22 +18,15 @@ using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Repositories;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Bit.Test.Common.Fakes;
|
||||
using Bit.Test.Common.Helpers;
|
||||
using Fido2NetLib;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
@ -179,9 +169,12 @@ public class UserServiceTests
|
||||
[Theory]
|
||||
[BitAutoData(DeviceType.UnknownBrowser, "Unknown Browser")]
|
||||
[BitAutoData(DeviceType.Android, "Android")]
|
||||
public async Task SendNewDeviceVerificationEmailAsync_DeviceMatches(DeviceType deviceType, string deviceTypeName, SutProvider<UserService> sutProvider, User user)
|
||||
public async Task SendNewDeviceVerificationEmailAsync_DeviceMatches(DeviceType deviceType, string deviceTypeName,
|
||||
User user)
|
||||
{
|
||||
SetupFakeTokenProvider(sutProvider, user);
|
||||
var sutProvider = new SutProvider<UserService>()
|
||||
.CreateWithUserServiceCustomizations(user);
|
||||
|
||||
var context = sutProvider.GetDependency<ICurrentContext>();
|
||||
context.DeviceType = deviceType;
|
||||
context.IpAddress = "1.1.1.1";
|
||||
@ -194,9 +187,11 @@ public class UserServiceTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SendNewDeviceVerificationEmailAsync_NullDeviceTypeShouldSendUnkownBrowserType(SutProvider<UserService> sutProvider, User user)
|
||||
public async Task SendNewDeviceVerificationEmailAsync_NullDeviceTypeShouldSendUnkownBrowserType(User user)
|
||||
{
|
||||
SetupFakeTokenProvider(sutProvider, user);
|
||||
var sutProvider = new SutProvider<UserService>()
|
||||
.CreateWithUserServiceCustomizations(user);
|
||||
|
||||
var context = sutProvider.GetDependency<ICurrentContext>();
|
||||
context.DeviceType = null;
|
||||
context.IpAddress = "1.1.1.1";
|
||||
@ -266,76 +261,28 @@ public class UserServiceTests
|
||||
[BitAutoData(true, "bad_test_password", false, ShouldCheck.Password | ShouldCheck.OTP)]
|
||||
public async Task VerifySecretAsync_Works(
|
||||
bool shouldHavePassword, string secret, bool expectedIsVerified, ShouldCheck shouldCheck, // inline theory data
|
||||
SutProvider<UserService> sutProvider, User user) // AutoFixture injected data
|
||||
User user) // AutoFixture injected data
|
||||
{
|
||||
// Arrange
|
||||
var tokenProvider = SetupFakeTokenProvider(sutProvider, user);
|
||||
SetupUserAndDevice(user, shouldHavePassword);
|
||||
|
||||
var sutProvider = new SutProvider<UserService>()
|
||||
.CreateWithUserServiceCustomizations(user);
|
||||
|
||||
// Setup the fake password verification
|
||||
var substitutedUserPasswordStore = Substitute.For<IUserPasswordStore<User>>();
|
||||
substitutedUserPasswordStore
|
||||
sutProvider.GetDependency<IUserPasswordStore<User>>()
|
||||
.GetPasswordHashAsync(user, Arg.Any<CancellationToken>())
|
||||
.Returns((ci) =>
|
||||
{
|
||||
return Task.FromResult("hashed_test_password");
|
||||
});
|
||||
.Returns(Task.FromResult("hashed_test_password"));
|
||||
|
||||
sutProvider.SetDependency<IUserStore<User>>(substitutedUserPasswordStore, "store");
|
||||
|
||||
sutProvider.GetDependency<IPasswordHasher<User>>("passwordHasher")
|
||||
sutProvider.GetDependency<IPasswordHasher<User>>()
|
||||
.VerifyHashedPassword(user, "hashed_test_password", "test_password")
|
||||
.Returns((ci) =>
|
||||
{
|
||||
return PasswordVerificationResult.Success;
|
||||
});
|
||||
.Returns(PasswordVerificationResult.Success);
|
||||
|
||||
// HACK: SutProvider is being weird about not injecting the IPasswordHasher that I configured
|
||||
var sut = new UserService(
|
||||
sutProvider.GetDependency<IUserRepository>(),
|
||||
sutProvider.GetDependency<ICipherRepository>(),
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>(),
|
||||
sutProvider.GetDependency<IOrganizationRepository>(),
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>(),
|
||||
sutProvider.GetDependency<IMailService>(),
|
||||
sutProvider.GetDependency<IPushNotificationService>(),
|
||||
sutProvider.GetDependency<IUserStore<User>>(),
|
||||
sutProvider.GetDependency<IOptions<IdentityOptions>>(),
|
||||
sutProvider.GetDependency<IPasswordHasher<User>>(),
|
||||
sutProvider.GetDependency<IEnumerable<IUserValidator<User>>>(),
|
||||
sutProvider.GetDependency<IEnumerable<IPasswordValidator<User>>>(),
|
||||
sutProvider.GetDependency<ILookupNormalizer>(),
|
||||
sutProvider.GetDependency<IdentityErrorDescriber>(),
|
||||
sutProvider.GetDependency<IServiceProvider>(),
|
||||
sutProvider.GetDependency<ILogger<UserManager<User>>>(),
|
||||
sutProvider.GetDependency<ILicensingService>(),
|
||||
sutProvider.GetDependency<IEventService>(),
|
||||
sutProvider.GetDependency<IApplicationCacheService>(),
|
||||
sutProvider.GetDependency<IDataProtectionProvider>(),
|
||||
sutProvider.GetDependency<IPaymentService>(),
|
||||
sutProvider.GetDependency<IPolicyRepository>(),
|
||||
sutProvider.GetDependency<IPolicyService>(),
|
||||
sutProvider.GetDependency<IFido2>(),
|
||||
sutProvider.GetDependency<ICurrentContext>(),
|
||||
sutProvider.GetDependency<IGlobalSettings>(),
|
||||
sutProvider.GetDependency<IAcceptOrgUserCommand>(),
|
||||
sutProvider.GetDependency<IProviderUserRepository>(),
|
||||
sutProvider.GetDependency<IStripeSyncService>(),
|
||||
new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>(),
|
||||
sutProvider.GetDependency<IFeatureService>(),
|
||||
sutProvider.GetDependency<IPremiumUserBillingService>(),
|
||||
sutProvider.GetDependency<IRemoveOrganizationUserCommand>(),
|
||||
sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>(),
|
||||
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>(),
|
||||
sutProvider.GetDependency<IDistributedCache>(),
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||
);
|
||||
|
||||
var actualIsVerified = await sut.VerifySecretAsync(user, secret);
|
||||
var actualIsVerified = await sutProvider.Sut.VerifySecretAsync(user, secret);
|
||||
|
||||
Assert.Equal(expectedIsVerified, actualIsVerified);
|
||||
|
||||
await tokenProvider
|
||||
await sutProvider.GetDependency<IUserTwoFactorTokenProvider<User>>()
|
||||
.Received(shouldCheck.HasFlag(ShouldCheck.OTP) ? 1 : 0)
|
||||
.ValidateAsync(Arg.Any<string>(), secret, Arg.Any<UserManager<User>>(), user);
|
||||
|
||||
@ -661,26 +608,25 @@ public class UserServiceTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ResendNewDeviceVerificationEmail_SendsToken_Success(
|
||||
SutProvider<UserService> sutProvider, User user)
|
||||
public async Task ResendNewDeviceVerificationEmail_SendsToken_Success(User user)
|
||||
{
|
||||
// Arrange
|
||||
var testPassword = "test_password";
|
||||
var tokenProvider = SetupFakeTokenProvider(sutProvider, user);
|
||||
SetupUserAndDevice(user, true);
|
||||
|
||||
var sutProvider = new SutProvider<UserService>()
|
||||
.CreateWithUserServiceCustomizations(user);
|
||||
|
||||
// Setup the fake password verification
|
||||
var substitutedUserPasswordStore = Substitute.For<IUserPasswordStore<User>>();
|
||||
substitutedUserPasswordStore
|
||||
sutProvider
|
||||
.GetDependency<IUserPasswordStore<User>>()
|
||||
.GetPasswordHashAsync(user, Arg.Any<CancellationToken>())
|
||||
.Returns((ci) =>
|
||||
{
|
||||
return Task.FromResult("hashed_test_password");
|
||||
});
|
||||
|
||||
sutProvider.SetDependency<IUserStore<User>>(substitutedUserPasswordStore, "store");
|
||||
|
||||
sutProvider.GetDependency<IPasswordHasher<User>>("passwordHasher")
|
||||
sutProvider.GetDependency<IPasswordHasher<User>>()
|
||||
.VerifyHashedPassword(user, "hashed_test_password", testPassword)
|
||||
.Returns((ci) =>
|
||||
{
|
||||
@ -695,10 +641,7 @@ public class UserServiceTests
|
||||
context.DeviceType = DeviceType.Android;
|
||||
context.IpAddress = "1.1.1.1";
|
||||
|
||||
// HACK: SutProvider is being weird about not injecting the IPasswordHasher that I configured
|
||||
var sut = RebuildSut(sutProvider);
|
||||
|
||||
await sut.ResendNewDeviceVerificationEmail(user.Email, testPassword);
|
||||
await sutProvider.Sut.ResendNewDeviceVerificationEmail(user.Email, testPassword);
|
||||
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
@ -842,8 +785,15 @@ public class UserServiceTests
|
||||
user.MasterPassword = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IUserTwoFactorTokenProvider<User> SetupFakeTokenProvider(SutProvider<UserService> sutProvider, User user)
|
||||
public static class UserServiceSutProviderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Arranges a fake token provider. Must call as part of a builder pattern that ends in Create(), as it modifies
|
||||
/// the SutProvider build chain.
|
||||
/// </summary>
|
||||
private static SutProvider<UserService> SetFakeTokenProvider(this SutProvider<UserService> sutProvider, User user)
|
||||
{
|
||||
var fakeUserTwoFactorProvider = Substitute.For<IUserTwoFactorTokenProvider<User>>();
|
||||
|
||||
@ -859,8 +809,11 @@ public class UserServiceTests
|
||||
.ValidateAsync(Arg.Any<string>(), "otp_token", Arg.Any<UserManager<User>>(), user)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOptions<IdentityOptions>>()
|
||||
.Value.Returns(new IdentityOptions
|
||||
var fakeIdentityOptions = Substitute.For<IOptions<IdentityOptions>>();
|
||||
|
||||
fakeIdentityOptions
|
||||
.Value
|
||||
.Returns(new IdentityOptions
|
||||
{
|
||||
Tokens = new TokenOptions
|
||||
{
|
||||
@ -874,54 +827,54 @@ public class UserServiceTests
|
||||
}
|
||||
});
|
||||
|
||||
// The above arranging of dependencies is used in the constructor of UserManager
|
||||
// ref: https://github.com/dotnet/aspnetcore/blob/bfeb3bf9005c36b081d1e48725531ee0e15a9dfb/src/Identity/Extensions.Core/src/UserManager.cs#L103-L120
|
||||
// since the constructor of the Sut has ran already (when injected) I need to recreate it to get it to run again
|
||||
sutProvider.Create();
|
||||
sutProvider.SetDependency(fakeIdentityOptions);
|
||||
// Also set the fake provider dependency so that we can retrieve it easily via GetDependency
|
||||
sutProvider.SetDependency(fakeUserTwoFactorProvider);
|
||||
|
||||
return fakeUserTwoFactorProvider;
|
||||
return sutProvider;
|
||||
}
|
||||
|
||||
private IUserService RebuildSut(SutProvider<UserService> sutProvider)
|
||||
/// <summary>
|
||||
/// Properly registers IUserPasswordStore as IUserStore so it's injected when the sut is initialized.
|
||||
/// </summary>
|
||||
/// <param name="sutProvider"></param>
|
||||
/// <returns></returns>
|
||||
private static SutProvider<UserService> SetUserPasswordStore(this SutProvider<UserService> sutProvider)
|
||||
{
|
||||
return new UserService(
|
||||
sutProvider.GetDependency<IUserRepository>(),
|
||||
sutProvider.GetDependency<ICipherRepository>(),
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>(),
|
||||
sutProvider.GetDependency<IOrganizationRepository>(),
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>(),
|
||||
sutProvider.GetDependency<IMailService>(),
|
||||
sutProvider.GetDependency<IPushNotificationService>(),
|
||||
sutProvider.GetDependency<IUserStore<User>>(),
|
||||
sutProvider.GetDependency<IOptions<IdentityOptions>>(),
|
||||
sutProvider.GetDependency<IPasswordHasher<User>>(),
|
||||
sutProvider.GetDependency<IEnumerable<IUserValidator<User>>>(),
|
||||
sutProvider.GetDependency<IEnumerable<IPasswordValidator<User>>>(),
|
||||
sutProvider.GetDependency<ILookupNormalizer>(),
|
||||
sutProvider.GetDependency<IdentityErrorDescriber>(),
|
||||
sutProvider.GetDependency<IServiceProvider>(),
|
||||
sutProvider.GetDependency<ILogger<UserManager<User>>>(),
|
||||
sutProvider.GetDependency<ILicensingService>(),
|
||||
sutProvider.GetDependency<IEventService>(),
|
||||
sutProvider.GetDependency<IApplicationCacheService>(),
|
||||
sutProvider.GetDependency<IDataProtectionProvider>(),
|
||||
sutProvider.GetDependency<IPaymentService>(),
|
||||
sutProvider.GetDependency<IPolicyRepository>(),
|
||||
sutProvider.GetDependency<IPolicyService>(),
|
||||
sutProvider.GetDependency<IFido2>(),
|
||||
sutProvider.GetDependency<ICurrentContext>(),
|
||||
sutProvider.GetDependency<IGlobalSettings>(),
|
||||
sutProvider.GetDependency<IAcceptOrgUserCommand>(),
|
||||
sutProvider.GetDependency<IProviderUserRepository>(),
|
||||
sutProvider.GetDependency<IStripeSyncService>(),
|
||||
new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>(),
|
||||
sutProvider.GetDependency<IFeatureService>(),
|
||||
sutProvider.GetDependency<IPremiumUserBillingService>(),
|
||||
sutProvider.GetDependency<IRemoveOrganizationUserCommand>(),
|
||||
sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>(),
|
||||
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>(),
|
||||
sutProvider.GetDependency<IDistributedCache>(),
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||
);
|
||||
var substitutedUserPasswordStore = Substitute.For<IUserPasswordStore<User>>();
|
||||
|
||||
// IUserPasswordStore must be registered under the IUserStore parameter to be properly injected
|
||||
// because this is what the constructor expects
|
||||
sutProvider.SetDependency<IUserStore<User>>(substitutedUserPasswordStore);
|
||||
|
||||
// Also store it under its own type for retrieval and configuration
|
||||
sutProvider.SetDependency(substitutedUserPasswordStore);
|
||||
|
||||
return sutProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is a hack: when autofixture initializes the sut in sutProvider, it overwrites the public
|
||||
/// PasswordHasher property with a new substitute, so it loses the configured sutProvider mock.
|
||||
/// This doesn't usually happen because our dependencies are not usually public.
|
||||
/// Call this AFTER SutProvider.Create().
|
||||
/// </summary>
|
||||
private static SutProvider<UserService> FixPasswordHasherBug(this SutProvider<UserService> sutProvider)
|
||||
{
|
||||
// Get the configured sutProvider mock and assign it back to the public property in the base class
|
||||
sutProvider.Sut.PasswordHasher = sutProvider.GetDependency<IPasswordHasher<User>>();
|
||||
return sutProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A helper that combines all SutProvider configuration usually required for UserService.
|
||||
/// Call this instead of SutProvider.Create, after any additional configuration your test needs.
|
||||
/// </summary>
|
||||
public static SutProvider<UserService> CreateWithUserServiceCustomizations(this SutProvider<UserService> sutProvider, User user)
|
||||
=> sutProvider
|
||||
.SetUserPasswordStore()
|
||||
.SetFakeTokenProvider(user)
|
||||
.Create()
|
||||
.FixPasswordHasherBug();
|
||||
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
Loading…
x
Reference in New Issue
Block a user