mirror of
https://github.com/bitwarden/server
synced 2025-12-26 13:13:24 +00:00
Merge branch 'main' into billing/PM-24964/msp-unable-verfy-bank-account
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
|
||||
using System.Globalization;
|
||||
using Bit.Commercial.Core.Billing.Providers.Models;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
@@ -282,7 +283,7 @@ public class ProviderBillingService(
|
||||
]
|
||||
};
|
||||
|
||||
if (providerCustomer.Address is not { Country: "US" })
|
||||
if (providerCustomer.Address is not { Country: Constants.CountryAbbreviations.UnitedStates })
|
||||
{
|
||||
customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse;
|
||||
}
|
||||
@@ -525,7 +526,7 @@ public class ProviderBillingService(
|
||||
}
|
||||
};
|
||||
|
||||
if (taxInfo.BillingAddressCountry is not "US")
|
||||
if (taxInfo.BillingAddressCountry is not Constants.CountryAbbreviations.UnitedStates)
|
||||
{
|
||||
options.TaxExempt = StripeConstants.TaxExempt.Reverse;
|
||||
}
|
||||
|
||||
@@ -11,9 +11,18 @@ dotnet tool restore
|
||||
Set-Location "./src/Identity"
|
||||
dotnet build
|
||||
dotnet swagger tofile --output "../../identity.json" --host "https://identity.bitwarden.com" "./bin/Debug/net8.0/Identity.dll" "v1"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
# Api internal & public
|
||||
Set-Location "../../src/Api"
|
||||
dotnet build
|
||||
dotnet swagger tofile --output "../../api.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "internal"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
dotnet swagger tofile --output "../../api.public.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "public"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@@ -139,7 +140,7 @@ public class OrganizationCreateRequestModel : IValidatableObject
|
||||
new string[] { nameof(BillingAddressCountry) });
|
||||
}
|
||||
|
||||
if (PlanType != PlanType.Free && BillingAddressCountry == "US" &&
|
||||
if (PlanType != PlanType.Free && BillingAddressCountry == Constants.CountryAbbreviations.UnitedStates &&
|
||||
string.IsNullOrWhiteSpace(BillingAddressPostalCode))
|
||||
{
|
||||
yield return new ValidationResult("Zip / postal code is required.",
|
||||
|
||||
@@ -344,7 +344,6 @@ public class AccountsController : Controller
|
||||
}
|
||||
|
||||
[HttpPut("profile")]
|
||||
[HttpPost("profile")]
|
||||
public async Task<ProfileResponseModel> PutProfile([FromBody] UpdateProfileRequestModel model)
|
||||
{
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
@@ -363,8 +362,14 @@ public class AccountsController : Controller
|
||||
return response;
|
||||
}
|
||||
|
||||
[HttpPost("profile")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /profile instead.")]
|
||||
public async Task<ProfileResponseModel> PostProfile([FromBody] UpdateProfileRequestModel model)
|
||||
{
|
||||
return await PutProfile(model);
|
||||
}
|
||||
|
||||
[HttpPut("avatar")]
|
||||
[HttpPost("avatar")]
|
||||
public async Task<ProfileResponseModel> PutAvatar([FromBody] UpdateAvatarRequestModel model)
|
||||
{
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
@@ -382,6 +387,13 @@ public class AccountsController : Controller
|
||||
return response;
|
||||
}
|
||||
|
||||
[HttpPost("avatar")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /avatar instead.")]
|
||||
public async Task<ProfileResponseModel> PostAvatar([FromBody] UpdateAvatarRequestModel model)
|
||||
{
|
||||
return await PutAvatar(model);
|
||||
}
|
||||
|
||||
[HttpGet("revision-date")]
|
||||
public async Task<long?> GetAccountRevisionDate()
|
||||
{
|
||||
@@ -430,7 +442,6 @@ public class AccountsController : Controller
|
||||
}
|
||||
|
||||
[HttpDelete]
|
||||
[HttpPost("delete")]
|
||||
public async Task Delete([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
@@ -467,6 +478,13 @@ public class AccountsController : Controller
|
||||
throw new BadRequestException(ModelState);
|
||||
}
|
||||
|
||||
[HttpPost("delete")]
|
||||
[Obsolete("This endpoint is deprecated. Use DELETE / instead.")]
|
||||
public async Task PostDelete([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
await Delete(model);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("delete-recover")]
|
||||
public async Task PostDeleteRecover([FromBody] DeleteRecoverRequestModel model)
|
||||
@@ -638,7 +656,6 @@ public class AccountsController : Controller
|
||||
await _twoFactorEmailService.SendNewDeviceVerificationEmailAsync(user);
|
||||
}
|
||||
|
||||
[HttpPost("verify-devices")]
|
||||
[HttpPut("verify-devices")]
|
||||
public async Task SetUserVerifyDevicesAsync([FromBody] SetVerifyDevicesRequestModel request)
|
||||
{
|
||||
@@ -654,6 +671,13 @@ public class AccountsController : Controller
|
||||
await _userService.SaveUserAsync(user);
|
||||
}
|
||||
|
||||
[HttpPost("verify-devices")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /verify-devices instead.")]
|
||||
public async Task PostSetUserVerifyDevicesAsync([FromBody] SetVerifyDevicesRequestModel request)
|
||||
{
|
||||
await SetUserVerifyDevicesAsync(request);
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
|
||||
{
|
||||
var organizationsClaimingUser = await _userService.GetOrganizationsClaimingUserAsync(userId);
|
||||
|
||||
@@ -31,7 +31,7 @@ public class AuthRequestsController(
|
||||
private readonly IAuthRequestService _authRequestService = authRequestService;
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<AuthRequestResponseModel>> Get()
|
||||
public async Task<ListResponseModel<AuthRequestResponseModel>> GetAll()
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var authRequests = await _authRequestRepository.GetManyByUserIdAsync(userId);
|
||||
|
||||
@@ -79,7 +79,6 @@ public class EmergencyAccessController : Controller
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
[HttpPost("{id}")]
|
||||
public async Task Put(Guid id, [FromBody] EmergencyAccessUpdateRequestModel model)
|
||||
{
|
||||
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id);
|
||||
@@ -92,14 +91,27 @@ public class EmergencyAccessController : Controller
|
||||
await _emergencyAccessService.SaveAsync(model.ToEmergencyAccess(emergencyAccess), user);
|
||||
}
|
||||
|
||||
[HttpPost("{id}")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")]
|
||||
public async Task Post(Guid id, [FromBody] EmergencyAccessUpdateRequestModel model)
|
||||
{
|
||||
await Put(id, model);
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[HttpPost("{id}/delete")]
|
||||
public async Task Delete(Guid id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User);
|
||||
await _emergencyAccessService.DeleteAsync(id, userId.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/delete")]
|
||||
[Obsolete("This endpoint is deprecated. Use DELETE /{id} instead.")]
|
||||
public async Task PostDelete(Guid id)
|
||||
{
|
||||
await Delete(id);
|
||||
}
|
||||
|
||||
[HttpPost("invite")]
|
||||
public async Task Invite([FromBody] EmergencyAccessInviteRequestModel model)
|
||||
{
|
||||
@@ -136,7 +148,7 @@ public class EmergencyAccessController : Controller
|
||||
}
|
||||
|
||||
[HttpPost("{id}/approve")]
|
||||
public async Task Accept(Guid id)
|
||||
public async Task Approve(Guid id)
|
||||
{
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
await _emergencyAccessService.ApproveAsync(id, user);
|
||||
|
||||
@@ -110,7 +110,6 @@ public class TwoFactorController : Controller
|
||||
}
|
||||
|
||||
[HttpPut("authenticator")]
|
||||
[HttpPost("authenticator")]
|
||||
public async Task<TwoFactorAuthenticatorResponseModel> PutAuthenticator(
|
||||
[FromBody] UpdateTwoFactorAuthenticatorRequestModel model)
|
||||
{
|
||||
@@ -133,6 +132,14 @@ public class TwoFactorController : Controller
|
||||
return response;
|
||||
}
|
||||
|
||||
[HttpPost("authenticator")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /authenticator instead.")]
|
||||
public async Task<TwoFactorAuthenticatorResponseModel> PostAuthenticator(
|
||||
[FromBody] UpdateTwoFactorAuthenticatorRequestModel model)
|
||||
{
|
||||
return await PutAuthenticator(model);
|
||||
}
|
||||
|
||||
[HttpDelete("authenticator")]
|
||||
public async Task<TwoFactorProviderResponseModel> DisableAuthenticator(
|
||||
[FromBody] TwoFactorAuthenticatorDisableRequestModel model)
|
||||
@@ -157,7 +164,6 @@ public class TwoFactorController : Controller
|
||||
}
|
||||
|
||||
[HttpPut("yubikey")]
|
||||
[HttpPost("yubikey")]
|
||||
public async Task<TwoFactorYubiKeyResponseModel> PutYubiKey([FromBody] UpdateTwoFactorYubicoOtpRequestModel model)
|
||||
{
|
||||
var user = await CheckAsync(model, true);
|
||||
@@ -174,6 +180,13 @@ public class TwoFactorController : Controller
|
||||
return response;
|
||||
}
|
||||
|
||||
[HttpPost("yubikey")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /yubikey instead.")]
|
||||
public async Task<TwoFactorYubiKeyResponseModel> PostYubiKey([FromBody] UpdateTwoFactorYubicoOtpRequestModel model)
|
||||
{
|
||||
return await PutYubiKey(model);
|
||||
}
|
||||
|
||||
[HttpPost("get-duo")]
|
||||
public async Task<TwoFactorDuoResponseModel> GetDuo([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
@@ -183,7 +196,6 @@ public class TwoFactorController : Controller
|
||||
}
|
||||
|
||||
[HttpPut("duo")]
|
||||
[HttpPost("duo")]
|
||||
public async Task<TwoFactorDuoResponseModel> PutDuo([FromBody] UpdateTwoFactorDuoRequestModel model)
|
||||
{
|
||||
var user = await CheckAsync(model, true);
|
||||
@@ -199,6 +211,13 @@ public class TwoFactorController : Controller
|
||||
return response;
|
||||
}
|
||||
|
||||
[HttpPost("duo")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /duo instead.")]
|
||||
public async Task<TwoFactorDuoResponseModel> PostDuo([FromBody] UpdateTwoFactorDuoRequestModel model)
|
||||
{
|
||||
return await PutDuo(model);
|
||||
}
|
||||
|
||||
[HttpPost("~/organizations/{id}/two-factor/get-duo")]
|
||||
public async Task<TwoFactorDuoResponseModel> GetOrganizationDuo(string id,
|
||||
[FromBody] SecretVerificationRequestModel model)
|
||||
@@ -217,7 +236,6 @@ public class TwoFactorController : Controller
|
||||
}
|
||||
|
||||
[HttpPut("~/organizations/{id}/two-factor/duo")]
|
||||
[HttpPost("~/organizations/{id}/two-factor/duo")]
|
||||
public async Task<TwoFactorDuoResponseModel> PutOrganizationDuo(string id,
|
||||
[FromBody] UpdateTwoFactorDuoRequestModel model)
|
||||
{
|
||||
@@ -243,6 +261,14 @@ public class TwoFactorController : Controller
|
||||
return response;
|
||||
}
|
||||
|
||||
[HttpPost("~/organizations/{id}/two-factor/duo")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /organizations/{id}/two-factor/duo instead.")]
|
||||
public async Task<TwoFactorDuoResponseModel> PostOrganizationDuo(string id,
|
||||
[FromBody] UpdateTwoFactorDuoRequestModel model)
|
||||
{
|
||||
return await PutOrganizationDuo(id, model);
|
||||
}
|
||||
|
||||
[HttpPost("get-webauthn")]
|
||||
public async Task<TwoFactorWebAuthnResponseModel> GetWebAuthn([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
@@ -261,7 +287,6 @@ public class TwoFactorController : Controller
|
||||
}
|
||||
|
||||
[HttpPut("webauthn")]
|
||||
[HttpPost("webauthn")]
|
||||
public async Task<TwoFactorWebAuthnResponseModel> PutWebAuthn([FromBody] TwoFactorWebAuthnRequestModel model)
|
||||
{
|
||||
var user = await CheckAsync(model, false);
|
||||
@@ -277,6 +302,13 @@ public class TwoFactorController : Controller
|
||||
return response;
|
||||
}
|
||||
|
||||
[HttpPost("webauthn")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /webauthn instead.")]
|
||||
public async Task<TwoFactorWebAuthnResponseModel> PostWebAuthn([FromBody] TwoFactorWebAuthnRequestModel model)
|
||||
{
|
||||
return await PutWebAuthn(model);
|
||||
}
|
||||
|
||||
[HttpDelete("webauthn")]
|
||||
public async Task<TwoFactorWebAuthnResponseModel> DeleteWebAuthn(
|
||||
[FromBody] TwoFactorWebAuthnDeleteRequestModel model)
|
||||
@@ -349,7 +381,6 @@ public class TwoFactorController : Controller
|
||||
}
|
||||
|
||||
[HttpPut("email")]
|
||||
[HttpPost("email")]
|
||||
public async Task<TwoFactorEmailResponseModel> PutEmail([FromBody] UpdateTwoFactorEmailRequestModel model)
|
||||
{
|
||||
var user = await CheckAsync(model, false);
|
||||
@@ -367,8 +398,14 @@ public class TwoFactorController : Controller
|
||||
return response;
|
||||
}
|
||||
|
||||
[HttpPost("email")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /email instead.")]
|
||||
public async Task<TwoFactorEmailResponseModel> PostEmail([FromBody] UpdateTwoFactorEmailRequestModel model)
|
||||
{
|
||||
return await PutEmail(model);
|
||||
}
|
||||
|
||||
[HttpPut("disable")]
|
||||
[HttpPost("disable")]
|
||||
public async Task<TwoFactorProviderResponseModel> PutDisable([FromBody] TwoFactorProviderRequestModel model)
|
||||
{
|
||||
var user = await CheckAsync(model, false);
|
||||
@@ -377,8 +414,14 @@ public class TwoFactorController : Controller
|
||||
return response;
|
||||
}
|
||||
|
||||
[HttpPost("disable")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /disable instead.")]
|
||||
public async Task<TwoFactorProviderResponseModel> PostDisable([FromBody] TwoFactorProviderRequestModel model)
|
||||
{
|
||||
return await PutDisable(model);
|
||||
}
|
||||
|
||||
[HttpPut("~/organizations/{id}/two-factor/disable")]
|
||||
[HttpPost("~/organizations/{id}/two-factor/disable")]
|
||||
public async Task<TwoFactorProviderResponseModel> PutOrganizationDisable(string id,
|
||||
[FromBody] TwoFactorProviderRequestModel model)
|
||||
{
|
||||
@@ -401,6 +444,14 @@ public class TwoFactorController : Controller
|
||||
return response;
|
||||
}
|
||||
|
||||
[HttpPost("~/organizations/{id}/two-factor/disable")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /organizations/{id}/two-factor/disable instead.")]
|
||||
public async Task<TwoFactorProviderResponseModel> PostOrganizationDisable(string id,
|
||||
[FromBody] TwoFactorProviderRequestModel model)
|
||||
{
|
||||
return await PutOrganizationDisable(id, model);
|
||||
}
|
||||
|
||||
[HttpPost("get-recover")]
|
||||
public async Task<TwoFactorRecoverResponseModel> GetRecover([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
@@ -409,21 +460,6 @@ public class TwoFactorController : Controller
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// To be removed when the feature flag pm-17128-recovery-code-login is removed PM-18175.
|
||||
/// </summary>
|
||||
[Obsolete("Two Factor recovery is handled in the TwoFactorAuthenticationValidator.")]
|
||||
[HttpPost("recover")]
|
||||
[AllowAnonymous]
|
||||
public async Task PostRecover([FromBody] TwoFactorRecoveryRequestModel model)
|
||||
{
|
||||
if (!await _userService.RecoverTwoFactorAsync(model.Email, model.MasterPasswordHash, model.RecoveryCode))
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException(string.Empty, "Invalid information. Try again.");
|
||||
}
|
||||
}
|
||||
|
||||
[Obsolete("Leaving this for backwards compatibility on clients")]
|
||||
[HttpGet("get-device-verification-settings")]
|
||||
public Task<DeviceVerificationResponseModel> GetDeviceVerificationSettings()
|
||||
|
||||
@@ -102,7 +102,7 @@ public class CollectionsController : Controller
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<CollectionResponseModel>> Get(Guid orgId)
|
||||
public async Task<ListResponseModel<CollectionResponseModel>> GetAll(Guid orgId)
|
||||
{
|
||||
IEnumerable<Collection> orgCollections;
|
||||
|
||||
@@ -173,7 +173,6 @@ public class CollectionsController : Controller
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
[HttpPost("{id}")]
|
||||
public async Task<CollectionResponseModel> Put(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model)
|
||||
{
|
||||
var collection = await _collectionRepository.GetByIdAsync(id);
|
||||
@@ -198,6 +197,13 @@ public class CollectionsController : Controller
|
||||
return new CollectionAccessDetailsResponseModel(collectionWithPermissions);
|
||||
}
|
||||
|
||||
[HttpPost("{id}")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")]
|
||||
public async Task<CollectionResponseModel> Post(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model)
|
||||
{
|
||||
return await Put(orgId, id, model);
|
||||
}
|
||||
|
||||
[HttpPost("bulk-access")]
|
||||
public async Task PostBulkCollectionAccess(Guid orgId, [FromBody] BulkCollectionAccessRequestModel model)
|
||||
{
|
||||
@@ -222,7 +228,6 @@ public class CollectionsController : Controller
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[HttpPost("{id}/delete")]
|
||||
public async Task Delete(Guid orgId, Guid id)
|
||||
{
|
||||
var collection = await _collectionRepository.GetByIdAsync(id);
|
||||
@@ -235,8 +240,14 @@ public class CollectionsController : Controller
|
||||
await _deleteCollectionCommand.DeleteAsync(collection);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/delete")]
|
||||
[Obsolete("This endpoint is deprecated. Use DELETE /{id} instead.")]
|
||||
public async Task PostDelete(Guid orgId, Guid id)
|
||||
{
|
||||
await Delete(orgId, id);
|
||||
}
|
||||
|
||||
[HttpDelete("")]
|
||||
[HttpPost("delete")]
|
||||
public async Task DeleteMany(Guid orgId, [FromBody] CollectionBulkDeleteRequestModel model)
|
||||
{
|
||||
var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Ids);
|
||||
@@ -248,4 +259,11 @@ public class CollectionsController : Controller
|
||||
|
||||
await _deleteCollectionCommand.DeleteManyAsync(collections);
|
||||
}
|
||||
|
||||
[HttpPost("delete")]
|
||||
[Obsolete("This endpoint is deprecated. Use DELETE / instead.")]
|
||||
public async Task PostDeleteMany(Guid orgId, [FromBody] CollectionBulkDeleteRequestModel model)
|
||||
{
|
||||
await DeleteMany(orgId, model);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ public class DevicesController : Controller
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<DeviceAuthRequestResponseModel>> Get()
|
||||
public async Task<ListResponseModel<DeviceAuthRequestResponseModel>> GetAll()
|
||||
{
|
||||
var devicesWithPendingAuthData = await _deviceRepository.GetManyByUserIdWithDeviceAuth(_userService.GetProperUserId(User).Value);
|
||||
|
||||
@@ -99,7 +99,6 @@ public class DevicesController : Controller
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
[HttpPost("{id}")]
|
||||
public async Task<DeviceResponseModel> Put(string id, [FromBody] DeviceRequestModel model)
|
||||
{
|
||||
var device = await _deviceRepository.GetByIdAsync(new Guid(id), _userService.GetProperUserId(User).Value);
|
||||
@@ -114,8 +113,14 @@ public class DevicesController : Controller
|
||||
return response;
|
||||
}
|
||||
|
||||
[HttpPost("{id}")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")]
|
||||
public async Task<DeviceResponseModel> Post(string id, [FromBody] DeviceRequestModel model)
|
||||
{
|
||||
return await Put(id, model);
|
||||
}
|
||||
|
||||
[HttpPut("{identifier}/keys")]
|
||||
[HttpPost("{identifier}/keys")]
|
||||
public async Task<DeviceResponseModel> PutKeys(string identifier, [FromBody] DeviceKeysRequestModel model)
|
||||
{
|
||||
var device = await _deviceRepository.GetByIdentifierAsync(identifier, _userService.GetProperUserId(User).Value);
|
||||
@@ -130,6 +135,13 @@ public class DevicesController : Controller
|
||||
return response;
|
||||
}
|
||||
|
||||
[HttpPost("{identifier}/keys")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /{identifier}/keys instead.")]
|
||||
public async Task<DeviceResponseModel> PostKeys(string identifier, [FromBody] DeviceKeysRequestModel model)
|
||||
{
|
||||
return await PutKeys(identifier, model);
|
||||
}
|
||||
|
||||
[HttpPost("{identifier}/retrieve-keys")]
|
||||
[Obsolete("This endpoint is deprecated. The keys are on the regular device GET endpoints now.")]
|
||||
public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier)
|
||||
@@ -187,7 +199,6 @@ public class DevicesController : Controller
|
||||
}
|
||||
|
||||
[HttpPut("identifier/{identifier}/token")]
|
||||
[HttpPost("identifier/{identifier}/token")]
|
||||
public async Task PutToken(string identifier, [FromBody] DeviceTokenRequestModel model)
|
||||
{
|
||||
var device = await _deviceRepository.GetByIdentifierAsync(identifier, _userService.GetProperUserId(User).Value);
|
||||
@@ -199,8 +210,14 @@ public class DevicesController : Controller
|
||||
await _deviceService.SaveAsync(model.ToDevice(device));
|
||||
}
|
||||
|
||||
[HttpPost("identifier/{identifier}/token")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /identifier/{identifier}/token instead.")]
|
||||
public async Task PostToken(string identifier, [FromBody] DeviceTokenRequestModel model)
|
||||
{
|
||||
await PutToken(identifier, model);
|
||||
}
|
||||
|
||||
[HttpPut("identifier/{identifier}/web-push-auth")]
|
||||
[HttpPost("identifier/{identifier}/web-push-auth")]
|
||||
public async Task PutWebPushAuth(string identifier, [FromBody] WebPushAuthRequestModel model)
|
||||
{
|
||||
var device = await _deviceRepository.GetByIdentifierAsync(identifier, _userService.GetProperUserId(User).Value);
|
||||
@@ -216,9 +233,15 @@ public class DevicesController : Controller
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPost("identifier/{identifier}/web-push-auth")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /identifier/{identifier}/web-push-auth instead.")]
|
||||
public async Task PostWebPushAuth(string identifier, [FromBody] WebPushAuthRequestModel model)
|
||||
{
|
||||
await PutWebPushAuth(identifier, model);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPut("identifier/{identifier}/clear-token")]
|
||||
[HttpPost("identifier/{identifier}/clear-token")]
|
||||
public async Task PutClearToken(string identifier)
|
||||
{
|
||||
var device = await _deviceRepository.GetByIdentifierAsync(identifier);
|
||||
@@ -230,8 +253,15 @@ public class DevicesController : Controller
|
||||
await _deviceService.ClearTokenAsync(device);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("identifier/{identifier}/clear-token")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /identifier/{identifier}/clear-token instead.")]
|
||||
public async Task PostClearToken(string identifier)
|
||||
{
|
||||
await PutClearToken(identifier);
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[HttpPost("{id}/deactivate")]
|
||||
public async Task Deactivate(string id)
|
||||
{
|
||||
var device = await _deviceRepository.GetByIdAsync(new Guid(id), _userService.GetProperUserId(User).Value);
|
||||
@@ -243,17 +273,24 @@ public class DevicesController : Controller
|
||||
await _deviceService.DeactivateAsync(device);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/deactivate")]
|
||||
[Obsolete("This endpoint is deprecated. Use DELETE /{id} instead.")]
|
||||
public async Task PostDeactivate(string id)
|
||||
{
|
||||
await Deactivate(id);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("knowndevice")]
|
||||
public async Task<bool> GetByIdentifierQuery(
|
||||
[Required][FromHeader(Name = "X-Request-Email")] string Email,
|
||||
[Required][FromHeader(Name = "X-Device-Identifier")] string DeviceIdentifier)
|
||||
=> await GetByIdentifier(CoreHelpers.Base64UrlDecodeString(Email), DeviceIdentifier);
|
||||
=> await GetByEmailAndIdentifier(CoreHelpers.Base64UrlDecodeString(Email), DeviceIdentifier);
|
||||
|
||||
[Obsolete("Path is deprecated due to encoding issues, use /knowndevice instead.")]
|
||||
[AllowAnonymous]
|
||||
[HttpGet("knowndevice/{email}/{identifier}")]
|
||||
public async Task<bool> GetByIdentifier(string email, string identifier)
|
||||
public async Task<bool> GetByEmailAndIdentifier(string email, string identifier)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(identifier))
|
||||
{
|
||||
|
||||
@@ -6,12 +6,18 @@ namespace Bit.Api.Controllers;
|
||||
public class InfoController : Controller
|
||||
{
|
||||
[HttpGet("~/alive")]
|
||||
[HttpGet("~/now")]
|
||||
public DateTime GetAlive()
|
||||
{
|
||||
return DateTime.UtcNow;
|
||||
}
|
||||
|
||||
[HttpGet("~/now")]
|
||||
[Obsolete("This endpoint is deprecated. Use GET /alive instead.")]
|
||||
public DateTime GetNow()
|
||||
{
|
||||
return GetAlive();
|
||||
}
|
||||
|
||||
[HttpGet("~/version")]
|
||||
public JsonResult GetVersion()
|
||||
{
|
||||
|
||||
@@ -32,7 +32,6 @@ public class SettingsController : Controller
|
||||
}
|
||||
|
||||
[HttpPut("domains")]
|
||||
[HttpPost("domains")]
|
||||
public async Task<DomainsResponseModel> PutDomains([FromBody] UpdateDomainsRequestModel model)
|
||||
{
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
@@ -46,4 +45,11 @@ public class SettingsController : Controller
|
||||
var response = new DomainsResponseModel(user);
|
||||
return response;
|
||||
}
|
||||
|
||||
[HttpPost("domains")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT /domains instead.")]
|
||||
public async Task<DomainsResponseModel> PostDomains([FromBody] UpdateDomainsRequestModel model)
|
||||
{
|
||||
return await PutDomains(model);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Bit.Api.Dirt.Models;
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
@@ -26,6 +27,7 @@ public class ReportsController : Controller
|
||||
private readonly IAddOrganizationReportCommand _addOrganizationReportCommand;
|
||||
private readonly IDropOrganizationReportCommand _dropOrganizationReportCommand;
|
||||
private readonly IGetOrganizationReportQuery _getOrganizationReportQuery;
|
||||
private readonly ILogger<ReportsController> _logger;
|
||||
|
||||
public ReportsController(
|
||||
ICurrentContext currentContext,
|
||||
@@ -36,7 +38,8 @@ public class ReportsController : Controller
|
||||
IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand,
|
||||
IGetOrganizationReportQuery getOrganizationReportQuery,
|
||||
IAddOrganizationReportCommand addOrganizationReportCommand,
|
||||
IDropOrganizationReportCommand dropOrganizationReportCommand
|
||||
IDropOrganizationReportCommand dropOrganizationReportCommand,
|
||||
ILogger<ReportsController> logger
|
||||
)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
@@ -48,6 +51,7 @@ public class ReportsController : Controller
|
||||
_getOrganizationReportQuery = getOrganizationReportQuery;
|
||||
_addOrganizationReportCommand = addOrganizationReportCommand;
|
||||
_dropOrganizationReportCommand = dropOrganizationReportCommand;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -86,32 +90,24 @@ public class ReportsController : Controller
|
||||
{
|
||||
if (!await _currentContext.AccessReports(orgId))
|
||||
{
|
||||
_logger.LogInformation(Constants.BypassFiltersEventId,
|
||||
"AccessReports Check - UserId: {userId} OrgId: {orgId} DeviceType: {deviceType}",
|
||||
_currentContext.UserId, orgId, _currentContext.DeviceType);
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var accessDetails = await GetMemberAccessDetails(new MemberAccessReportRequest { OrganizationId = orgId });
|
||||
_logger.LogInformation(Constants.BypassFiltersEventId,
|
||||
"MemberAccessReportQuery starts - UserId: {userId} OrgId: {orgId} DeviceType: {deviceType}",
|
||||
_currentContext.UserId, orgId, _currentContext.DeviceType);
|
||||
|
||||
var accessDetails = await _memberAccessReportQuery
|
||||
.GetMemberAccessReportsAsync(new MemberAccessReportRequest { OrganizationId = orgId });
|
||||
|
||||
var responses = accessDetails.Select(x => new MemberAccessDetailReportResponseModel(x));
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contains the organization member info, the cipher ids associated with the member,
|
||||
/// and details on their collections, groups, and permissions
|
||||
/// </summary>
|
||||
/// <param name="request">Request parameters</param>
|
||||
/// <returns>
|
||||
/// List of a user's permissions at a group and collection level as well as the number of ciphers
|
||||
/// associated with that group/collection
|
||||
/// </returns>
|
||||
private async Task<IEnumerable<MemberAccessReportDetail>> GetMemberAccessDetails(
|
||||
MemberAccessReportRequest request)
|
||||
{
|
||||
var accessDetails = await _memberAccessReportQuery.GetMemberAccessReportsAsync(request);
|
||||
return accessDetails;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the risk insights report details from the risk insights query. Associates a user to their cipher ids
|
||||
/// </summary>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Settings;
|
||||
using Enums = Bit.Core.Enums;
|
||||
|
||||
@@ -35,7 +36,7 @@ public class PremiumRequestModel : IValidatableObject
|
||||
{
|
||||
yield return new ValidationResult("Payment token or license is required.");
|
||||
}
|
||||
if (Country == "US" && string.IsNullOrWhiteSpace(PostalCode))
|
||||
if (Country == Constants.CountryAbbreviations.UnitedStates && string.IsNullOrWhiteSpace(PostalCode))
|
||||
{
|
||||
yield return new ValidationResult("Zip / postal code is required.",
|
||||
new string[] { nameof(PostalCode) });
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core;
|
||||
|
||||
namespace Bit.Api.Models.Request.Accounts;
|
||||
|
||||
@@ -13,7 +14,7 @@ public class TaxInfoUpdateRequestModel : IValidatableObject
|
||||
|
||||
public virtual IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (Country == "US" && string.IsNullOrWhiteSpace(PostalCode))
|
||||
if (Country == Constants.CountryAbbreviations.UnitedStates && string.IsNullOrWhiteSpace(PostalCode))
|
||||
{
|
||||
yield return new ValidationResult("Zip / postal code is required.",
|
||||
new string[] { nameof(PostalCode) });
|
||||
|
||||
@@ -63,7 +63,7 @@ public class ImportCiphersController : Controller
|
||||
}
|
||||
|
||||
[HttpPost("import-organization")]
|
||||
public async Task PostImport([FromQuery] string organizationId,
|
||||
public async Task PostImportOrganization([FromQuery] string organizationId,
|
||||
[FromBody] ImportOrganizationCiphersRequestModel model)
|
||||
{
|
||||
if (!_globalSettings.SelfHosted &&
|
||||
|
||||
@@ -192,7 +192,7 @@ public class SendsController : Controller
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<SendResponseModel>> Get()
|
||||
public async Task<ListResponseModel<SendResponseModel>> GetAll()
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var sends = await _sendRepository.GetManyByUserIdAsync(userId);
|
||||
|
||||
@@ -82,15 +82,7 @@ public static class ServiceCollectionExtensions
|
||||
config.DescribeAllParametersInCamelCase();
|
||||
// config.UseReferencedDefinitionsForEnums();
|
||||
|
||||
config.SchemaFilter<EnumSchemaFilter>();
|
||||
config.SchemaFilter<EncryptedStringSchemaFilter>();
|
||||
|
||||
// These two filters require debug symbols/git, so only add them in development mode
|
||||
if (environment.IsDevelopment())
|
||||
{
|
||||
config.DocumentFilter<GitCommitDocumentFilter>();
|
||||
config.OperationFilter<SourceFileLineOperationFilter>();
|
||||
}
|
||||
config.InitializeSwaggerFilters(environment);
|
||||
|
||||
var apiFilePath = Path.Combine(AppContext.BaseDirectory, "Api.xml");
|
||||
config.IncludeXmlComments(apiFilePath, true);
|
||||
|
||||
@@ -180,7 +180,7 @@ public class StripeEventService(
|
||||
|
||||
private static string GetCustomerRegion(IDictionary<string, string> customerMetadata)
|
||||
{
|
||||
const string defaultRegion = "US";
|
||||
const string defaultRegion = Core.Constants.CountryAbbreviations.UnitedStates;
|
||||
|
||||
if (customerMetadata.TryGetValue("region", out var value))
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
@@ -18,6 +19,7 @@ using Event = Stripe.Event;
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class UpcomingInvoiceHandler(
|
||||
IGetPaymentMethodQuery getPaymentMethodQuery,
|
||||
ILogger<StripeEventProcessor> logger,
|
||||
IMailService mailService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
@@ -137,7 +139,7 @@ public class UpcomingInvoiceHandler(
|
||||
|
||||
await AlignProviderTaxConcernsAsync(provider, subscription, parsedEvent.Id);
|
||||
|
||||
await SendUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice);
|
||||
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +160,42 @@ public class UpcomingInvoiceHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice, Subscription subscription, Guid providerId)
|
||||
{
|
||||
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
|
||||
|
||||
var items = invoice.FormatForProvider(subscription);
|
||||
|
||||
if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
|
||||
{
|
||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||
if (provider == null)
|
||||
{
|
||||
logger.LogWarning("Provider {ProviderId} not found for invoice upcoming email", providerId);
|
||||
return;
|
||||
}
|
||||
|
||||
var collectionMethod = subscription.CollectionMethod;
|
||||
var paymentMethod = await getPaymentMethodQuery.Run(provider);
|
||||
|
||||
var hasPaymentMethod = paymentMethod != null;
|
||||
var paymentMethodDescription = paymentMethod?.Match(
|
||||
bankAccount => $"Bank account ending in {bankAccount.Last4}",
|
||||
card => $"{card.Brand} ending in {card.Last4}",
|
||||
payPal => $"PayPal account {payPal.Email}"
|
||||
);
|
||||
|
||||
await mailService.SendProviderInvoiceUpcoming(
|
||||
validEmails,
|
||||
invoice.AmountDue / 100M,
|
||||
invoice.NextPaymentAttempt.Value,
|
||||
items,
|
||||
collectionMethod,
|
||||
hasPaymentMethod,
|
||||
paymentMethodDescription);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AlignOrganizationTaxConcernsAsync(
|
||||
Organization organization,
|
||||
Subscription subscription,
|
||||
@@ -165,7 +203,7 @@ public class UpcomingInvoiceHandler(
|
||||
{
|
||||
var nonUSBusinessUse =
|
||||
organization.PlanType.GetProductTier() != ProductTierType.Families &&
|
||||
subscription.Customer.Address.Country != "US";
|
||||
subscription.Customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates;
|
||||
|
||||
if (nonUSBusinessUse && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
|
||||
{
|
||||
@@ -210,7 +248,7 @@ public class UpcomingInvoiceHandler(
|
||||
Subscription subscription,
|
||||
string eventId)
|
||||
{
|
||||
if (subscription.Customer.Address.Country != "US" &&
|
||||
if (subscription.Customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates &&
|
||||
subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -9,12 +9,12 @@ public static class SendAccessClaimsPrincipalExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
var sendIdClaim = user.FindFirst(Claims.SendId)
|
||||
?? throw new InvalidOperationException("Send ID claim not found.");
|
||||
var sendIdClaim = user.FindFirst(Claims.SendAccessClaims.SendId)
|
||||
?? throw new InvalidOperationException("send_id claim not found.");
|
||||
|
||||
if (!Guid.TryParse(sendIdClaim.Value, out var sendGuid))
|
||||
{
|
||||
throw new InvalidOperationException("Invalid Send ID claim value.");
|
||||
throw new InvalidOperationException("Invalid send_id claim value.");
|
||||
}
|
||||
|
||||
return sendGuid;
|
||||
|
||||
@@ -22,6 +22,19 @@ public static class BillingExtensions
|
||||
_ => throw new BillingException($"PlanType {planType} could not be matched to a ProductTierType")
|
||||
};
|
||||
|
||||
public static bool IsBusinessProductTierType(this PlanType planType)
|
||||
=> IsBusinessProductTierType(planType.GetProductTier());
|
||||
|
||||
public static bool IsBusinessProductTierType(this ProductTierType productTierType)
|
||||
=> productTierType switch
|
||||
{
|
||||
ProductTierType.Free => false,
|
||||
ProductTierType.Families => false,
|
||||
ProductTierType.Enterprise => true,
|
||||
ProductTierType.Teams => true,
|
||||
ProductTierType.TeamsStarter => true
|
||||
};
|
||||
|
||||
public static bool IsBillable(this Provider provider) =>
|
||||
provider is
|
||||
{
|
||||
|
||||
76
src/Core/Billing/Extensions/InvoiceExtensions.cs
Normal file
76
src/Core/Billing/Extensions/InvoiceExtensions.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Extensions;
|
||||
|
||||
public static class InvoiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Formats invoice line items specifically for provider invoices, standardizing product descriptions
|
||||
/// and ensuring consistent tax representation.
|
||||
/// </summary>
|
||||
/// <param name="invoice">The Stripe invoice containing line items</param>
|
||||
/// <param name="subscription">The associated subscription (for future extensibility)</param>
|
||||
/// <returns>A list of formatted invoice item descriptions</returns>
|
||||
public static List<string> FormatForProvider(this Invoice invoice, Subscription subscription)
|
||||
{
|
||||
var items = new List<string>();
|
||||
|
||||
// Return empty list if no line items
|
||||
if (invoice.Lines == null)
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
foreach (var line in invoice.Lines.Data ?? new List<InvoiceLineItem>())
|
||||
{
|
||||
// Skip null lines or lines without description
|
||||
if (line?.Description == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var description = line.Description;
|
||||
|
||||
// Handle Provider Portal and Business Unit Portal service lines
|
||||
if (description.Contains("Provider Portal") || description.Contains("Business Unit"))
|
||||
{
|
||||
var priceMatch = Regex.Match(description, @"\(at \$[\d,]+\.?\d* / month\)");
|
||||
var priceInfo = priceMatch.Success ? priceMatch.Value : "";
|
||||
|
||||
var standardizedDescription = $"{line.Quantity} × Manage service provider {priceInfo}";
|
||||
items.Add(standardizedDescription);
|
||||
}
|
||||
// Handle tax lines
|
||||
else if (description.ToLower().Contains("tax"))
|
||||
{
|
||||
var priceMatch = Regex.Match(description, @"\(at \$[\d,]+\.?\d* / month\)");
|
||||
var priceInfo = priceMatch.Success ? priceMatch.Value : "";
|
||||
|
||||
// If no price info found in description, calculate from amount
|
||||
if (string.IsNullOrEmpty(priceInfo) && line.Quantity > 0)
|
||||
{
|
||||
var pricePerItem = (line.Amount / 100m) / line.Quantity;
|
||||
priceInfo = $"(at ${pricePerItem:F2} / month)";
|
||||
}
|
||||
|
||||
var taxDescription = $"{line.Quantity} × Tax {priceInfo}";
|
||||
items.Add(taxDescription);
|
||||
}
|
||||
// Handle other line items as-is
|
||||
else
|
||||
{
|
||||
items.Add(description);
|
||||
}
|
||||
}
|
||||
|
||||
// Add fallback tax from invoice-level tax if present and not already included
|
||||
if (invoice.Tax.HasValue && invoice.Tax.Value > 0)
|
||||
{
|
||||
var taxAmount = invoice.Tax.Value / 100m;
|
||||
items.Add($"1 × Tax (at ${taxAmount:F2} / month)");
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,6 @@ public static class ServiceCollectionExtensions
|
||||
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
|
||||
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
|
||||
services.AddTransient<ISubscriberService, SubscriberService>();
|
||||
services.AddKeyedTransient<IAutomaticTaxStrategy, PersonalUseAutomaticTaxStrategy>(AutomaticTaxFactory.PersonalUse);
|
||||
services.AddKeyedTransient<IAutomaticTaxStrategy, BusinessUseAutomaticTaxStrategy>(AutomaticTaxFactory.BusinessUse);
|
||||
services.AddTransient<IAutomaticTaxFactory, AutomaticTaxFactory>();
|
||||
services.AddLicenseServices();
|
||||
services.AddPricingClient();
|
||||
services.AddTransient<IPreviewTaxAmountCommand, PreviewTaxAmountCommand>();
|
||||
|
||||
@@ -275,7 +275,7 @@ public class OrganizationBillingService(
|
||||
|
||||
|
||||
if (planType.GetProductTier() is not ProductTierType.Free and not ProductTierType.Families &&
|
||||
customerSetup.TaxInformation.Country != "US")
|
||||
customerSetup.TaxInformation.Country != Core.Constants.CountryAbbreviations.UnitedStates)
|
||||
{
|
||||
customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse;
|
||||
}
|
||||
@@ -514,14 +514,14 @@ public class OrganizationBillingService(
|
||||
|
||||
customer = customer switch
|
||||
{
|
||||
{ Address.Country: not "US", TaxExempt: not StripeConstants.TaxExempt.Reverse } => await
|
||||
{ Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: not StripeConstants.TaxExempt.Reverse } => await
|
||||
stripeAdapter.CustomerUpdateAsync(customer.Id,
|
||||
new CustomerUpdateOptions
|
||||
{
|
||||
Expand = expansions,
|
||||
TaxExempt = StripeConstants.TaxExempt.Reverse
|
||||
}),
|
||||
{ Address.Country: "US", TaxExempt: StripeConstants.TaxExempt.Reverse } => await
|
||||
{ Address.Country: Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: StripeConstants.TaxExempt.Reverse } => await
|
||||
stripeAdapter.CustomerUpdateAsync(customer.Id,
|
||||
new CustomerUpdateOptions
|
||||
{
|
||||
|
||||
@@ -84,7 +84,7 @@ public class UpdateBillingAddressCommand(
|
||||
State = billingAddress.State
|
||||
},
|
||||
Expand = ["subscriptions", "tax_ids"],
|
||||
TaxExempt = billingAddress.Country != "US"
|
||||
TaxExempt = billingAddress.Country != Core.Constants.CountryAbbreviations.UnitedStates
|
||||
? StripeConstants.TaxExempt.Reverse
|
||||
: StripeConstants.TaxExempt.None
|
||||
});
|
||||
|
||||
@@ -801,15 +801,13 @@ public class SubscriberService(
|
||||
_ => false
|
||||
};
|
||||
|
||||
|
||||
|
||||
if (isBusinessUseSubscriber)
|
||||
{
|
||||
switch (customer)
|
||||
{
|
||||
case
|
||||
{
|
||||
Address.Country: not "US",
|
||||
Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates,
|
||||
TaxExempt: not TaxExempt.Reverse
|
||||
}:
|
||||
await stripeAdapter.CustomerUpdateAsync(customer.Id,
|
||||
@@ -817,7 +815,7 @@ public class SubscriberService(
|
||||
break;
|
||||
case
|
||||
{
|
||||
Address.Country: "US",
|
||||
Address.Country: Core.Constants.CountryAbbreviations.UnitedStates,
|
||||
TaxExempt: TaxExempt.Reverse
|
||||
}:
|
||||
await stripeAdapter.CustomerUpdateAsync(customer.Id,
|
||||
@@ -840,8 +838,8 @@ public class SubscriberService(
|
||||
{
|
||||
User => true,
|
||||
Organization organization => organization.PlanType.GetProductTier() == ProductTierType.Families ||
|
||||
customer.Address.Country == "US" || (customer.TaxIds?.Any() ?? false),
|
||||
Provider => customer.Address.Country == "US" || (customer.TaxIds?.Any() ?? false),
|
||||
customer.Address.Country == Core.Constants.CountryAbbreviations.UnitedStates || (customer.TaxIds?.Any() ?? false),
|
||||
Provider => customer.Address.Country == Core.Constants.CountryAbbreviations.UnitedStates || (customer.TaxIds?.Any() ?? false),
|
||||
_ => false
|
||||
};
|
||||
|
||||
|
||||
@@ -95,17 +95,11 @@ public class PreviewTaxAmountCommand(
|
||||
}
|
||||
}
|
||||
|
||||
if (planType.GetProductTier() == ProductTierType.Families)
|
||||
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
|
||||
if (parameters.PlanType.IsBusinessProductTierType() &&
|
||||
parameters.TaxInformation.Country != Core.Constants.CountryAbbreviations.UnitedStates)
|
||||
{
|
||||
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
|
||||
}
|
||||
else
|
||||
{
|
||||
options.AutomaticTax = new InvoiceAutomaticTaxOptions
|
||||
{
|
||||
Enabled = options.CustomerDetails.Address.Country == "US" ||
|
||||
options.CustomerDetails.TaxIds is [_, ..]
|
||||
};
|
||||
options.CustomerDetails.TaxExempt = StripeConstants.TaxExempt.Reverse;
|
||||
}
|
||||
|
||||
var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
using Bit.Core.Billing.Tax.Models;
|
||||
|
||||
namespace Bit.Core.Billing.Tax.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Responsible for defining the correct automatic tax strategy for either personal use of business use.
|
||||
/// </summary>
|
||||
public interface IAutomaticTaxFactory
|
||||
{
|
||||
Task<IAutomaticTaxStrategy> CreateAsync(AutomaticTaxFactoryParameters parameters);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
#nullable enable
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Tax.Services;
|
||||
|
||||
public interface IAutomaticTaxStrategy
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="subscription"></param>
|
||||
/// <returns>
|
||||
/// Returns <see cref="SubscriptionUpdateOptions" /> if changes are to be applied to the subscription, returns null
|
||||
/// otherwise.
|
||||
/// </returns>
|
||||
SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription);
|
||||
|
||||
/// <summary>
|
||||
/// Modifies an existing <see cref="SubscriptionCreateOptions" /> object with the automatic tax flag set correctly.
|
||||
/// </summary>
|
||||
/// <param name="options"></param>
|
||||
/// <param name="customer"></param>
|
||||
void SetCreateOptions(SubscriptionCreateOptions options, Customer customer);
|
||||
|
||||
/// <summary>
|
||||
/// Modifies an existing <see cref="SubscriptionUpdateOptions" /> object with the automatic tax flag set correctly.
|
||||
/// </summary>
|
||||
/// <param name="options"></param>
|
||||
/// <param name="subscription"></param>
|
||||
void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription);
|
||||
|
||||
void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Tax.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.Billing.Tax.Services.Implementations;
|
||||
|
||||
public class AutomaticTaxFactory(
|
||||
IFeatureService featureService,
|
||||
IPricingClient pricingClient) : IAutomaticTaxFactory
|
||||
{
|
||||
public const string BusinessUse = "business-use";
|
||||
public const string PersonalUse = "personal-use";
|
||||
|
||||
private readonly Lazy<Task<IEnumerable<string>>> _personalUsePlansTask = new(async () =>
|
||||
{
|
||||
var plans = await Task.WhenAll(
|
||||
pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019),
|
||||
pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually));
|
||||
|
||||
return plans.Select(plan => plan.PasswordManager.StripePlanId);
|
||||
});
|
||||
|
||||
public async Task<IAutomaticTaxStrategy> CreateAsync(AutomaticTaxFactoryParameters parameters)
|
||||
{
|
||||
if (parameters.Subscriber is User)
|
||||
{
|
||||
return new PersonalUseAutomaticTaxStrategy(featureService);
|
||||
}
|
||||
|
||||
if (parameters.PlanType.HasValue)
|
||||
{
|
||||
var plan = await pricingClient.GetPlanOrThrow(parameters.PlanType.Value);
|
||||
return plan.CanBeUsedByBusiness
|
||||
? new BusinessUseAutomaticTaxStrategy(featureService)
|
||||
: new PersonalUseAutomaticTaxStrategy(featureService);
|
||||
}
|
||||
|
||||
var personalUsePlans = await _personalUsePlansTask.Value;
|
||||
|
||||
if (parameters.Prices != null && parameters.Prices.Any(x => personalUsePlans.Any(y => y == x)))
|
||||
{
|
||||
return new PersonalUseAutomaticTaxStrategy(featureService);
|
||||
}
|
||||
|
||||
return new BusinessUseAutomaticTaxStrategy(featureService);
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Services;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Tax.Services.Implementations;
|
||||
|
||||
public class BusinessUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy
|
||||
{
|
||||
public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription)
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var shouldBeEnabled = ShouldBeEnabled(subscription.Customer);
|
||||
if (subscription.AutomaticTax.Enabled == shouldBeEnabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var options = new SubscriptionUpdateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = shouldBeEnabled
|
||||
},
|
||||
DefaultTaxRates = []
|
||||
};
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer)
|
||||
{
|
||||
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = ShouldBeEnabled(customer)
|
||||
};
|
||||
}
|
||||
|
||||
public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription)
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var shouldBeEnabled = ShouldBeEnabled(subscription.Customer);
|
||||
|
||||
if (subscription.AutomaticTax.Enabled == shouldBeEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = shouldBeEnabled
|
||||
};
|
||||
options.DefaultTaxRates = [];
|
||||
}
|
||||
|
||||
public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options)
|
||||
{
|
||||
options.AutomaticTax ??= new InvoiceAutomaticTaxOptions();
|
||||
|
||||
if (options.CustomerDetails.Address.Country == "US")
|
||||
{
|
||||
options.AutomaticTax.Enabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
options.AutomaticTax.Enabled = options.CustomerDetails.TaxIds != null && options.CustomerDetails.TaxIds.Any();
|
||||
}
|
||||
|
||||
private bool ShouldBeEnabled(Customer customer)
|
||||
{
|
||||
if (!customer.HasRecognizedTaxLocation())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (customer.Address.Country == "US")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (customer.TaxIds == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(customer.TaxIds), "`customer.tax_ids` must be expanded.");
|
||||
}
|
||||
|
||||
return customer.TaxIds.Any();
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Services;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Tax.Services.Implementations;
|
||||
|
||||
public class PersonalUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy
|
||||
{
|
||||
public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer)
|
||||
{
|
||||
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = ShouldBeEnabled(customer)
|
||||
};
|
||||
}
|
||||
|
||||
public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription)
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
{
|
||||
return;
|
||||
}
|
||||
options.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = ShouldBeEnabled(subscription.Customer)
|
||||
};
|
||||
options.DefaultTaxRates = [];
|
||||
}
|
||||
|
||||
public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription)
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (subscription.AutomaticTax.Enabled == ShouldBeEnabled(subscription.Customer))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var options = new SubscriptionUpdateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = ShouldBeEnabled(subscription.Customer),
|
||||
},
|
||||
DefaultTaxRates = []
|
||||
};
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options)
|
||||
{
|
||||
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
|
||||
}
|
||||
|
||||
private static bool ShouldBeEnabled(Customer customer)
|
||||
{
|
||||
return customer.HasRecognizedTaxLocation();
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,19 @@ public static class Constants
|
||||
/// regardless of whether there is a proration or not.
|
||||
/// </summary>
|
||||
public const string AlwaysInvoice = "always_invoice";
|
||||
|
||||
/// <summary>
|
||||
/// Used primarily to determine whether a customer's business is inside or outside the United States
|
||||
/// for billing purposes.
|
||||
/// </summary>
|
||||
public static class CountryAbbreviations
|
||||
{
|
||||
/// <summary>
|
||||
/// Abbreviation for The United States.
|
||||
/// This value must match what Stripe uses for the `Country` field value for the United States.
|
||||
/// </summary>
|
||||
public const string UnitedStates = "US";
|
||||
}
|
||||
}
|
||||
|
||||
public static class AuthConstants
|
||||
@@ -114,6 +127,7 @@ public static class FeatureFlagKeys
|
||||
public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions";
|
||||
public const string CreateDefaultLocation = "pm-19467-create-default-location";
|
||||
public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal";
|
||||
public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service";
|
||||
|
||||
/* Auth Team */
|
||||
public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence";
|
||||
@@ -121,7 +135,6 @@ public static class FeatureFlagKeys
|
||||
public const string BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals";
|
||||
public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor";
|
||||
public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor";
|
||||
public const string RecoveryCodeLogin = "pm-17128-recovery-code-login";
|
||||
public const string Otp6Digits = "pm-18612-otp-6-digits";
|
||||
public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email";
|
||||
|
||||
@@ -164,6 +177,7 @@ public static class FeatureFlagKeys
|
||||
public const string UserSdkForDecryption = "use-sdk-for-decryption";
|
||||
public const string PM17987_BlockType0 = "pm-17987-block-type-0";
|
||||
public const string ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings";
|
||||
public const string UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data";
|
||||
|
||||
/* Mobile Team */
|
||||
public const string NativeCarouselFlow = "native-carousel-flow";
|
||||
@@ -188,6 +202,7 @@ public static class FeatureFlagKeys
|
||||
public const string PersistPopupView = "persist-popup-view";
|
||||
public const string IpcChannelFramework = "ipc-channel-framework";
|
||||
public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked";
|
||||
public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users";
|
||||
|
||||
/* Tools Team */
|
||||
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="4.0.0.19" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="4.0.0.21" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="4.0.0.20" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="4.0.1.1" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" />
|
||||
|
||||
@@ -7,25 +7,40 @@ using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
using Bit.Core.Dirt.Reports.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures;
|
||||
|
||||
public class MemberAccessReportQuery(
|
||||
IOrganizationMemberBaseDetailRepository organizationMemberBaseDetailRepository,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IApplicationCacheService applicationCacheService) : IMemberAccessReportQuery
|
||||
IApplicationCacheService applicationCacheService,
|
||||
ILogger<MemberAccessReportQuery> logger) : IMemberAccessReportQuery
|
||||
{
|
||||
public async Task<IEnumerable<MemberAccessReportDetail>> GetMemberAccessReportsAsync(
|
||||
MemberAccessReportRequest request)
|
||||
{
|
||||
logger.LogInformation(Constants.BypassFiltersEventId, "Starting MemberAccessReport generation for OrganizationId: {OrganizationId}", request.OrganizationId);
|
||||
|
||||
var baseDetails =
|
||||
await organizationMemberBaseDetailRepository.GetOrganizationMemberBaseDetailsByOrganizationId(
|
||||
request.OrganizationId);
|
||||
|
||||
logger.LogInformation(Constants.BypassFiltersEventId, "Retrieved {BaseDetailsCount} base details for OrganizationId: {OrganizationId}",
|
||||
baseDetails.Count(), request.OrganizationId);
|
||||
|
||||
var orgUsers = baseDetails.Select(x => x.UserGuid.GetValueOrDefault()).Distinct();
|
||||
var orgUsersCount = orgUsers.Count();
|
||||
logger.LogInformation(Constants.BypassFiltersEventId, "Found {UniqueUsersCount} unique users for OrganizationId: {OrganizationId}",
|
||||
orgUsersCount, request.OrganizationId);
|
||||
|
||||
var orgUsersTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
|
||||
logger.LogInformation(Constants.BypassFiltersEventId, "Retrieved two-factor status for {UsersCount} users for OrganizationId: {OrganizationId}",
|
||||
orgUsersTwoFactorEnabled.Count(), request.OrganizationId);
|
||||
|
||||
var orgAbility = await applicationCacheService.GetOrganizationAbilityAsync(request.OrganizationId);
|
||||
logger.LogInformation(Constants.BypassFiltersEventId, "Retrieved organization ability (UseResetPassword: {UseResetPassword}) for OrganizationId: {OrganizationId}",
|
||||
orgAbility?.UseResetPassword, request.OrganizationId);
|
||||
|
||||
var accessDetails = baseDetails
|
||||
.GroupBy(b => new
|
||||
@@ -62,6 +77,10 @@ public class MemberAccessReportQuery(
|
||||
CipherIds = g.Select(c => c.CipherId)
|
||||
});
|
||||
|
||||
var accessDetailsCount = accessDetails.Count();
|
||||
logger.LogInformation(Constants.BypassFiltersEventId, "Completed MemberAccessReport generation for OrganizationId: {OrganizationId}. Generated {AccessDetailsCount} access detail records",
|
||||
request.OrganizationId, accessDetailsCount);
|
||||
|
||||
return accessDetails;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ public static class Claims
|
||||
public const string ManageResetPassword = "manageresetpassword";
|
||||
public const string ManageScim = "managescim";
|
||||
}
|
||||
|
||||
public const string SendId = "send_id";
|
||||
public static class SendAccessClaims
|
||||
{
|
||||
public const string SendId = "send_id";
|
||||
public const string Email = "send_email";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
||||
private readonly IdentityErrorDescriber _identityErrorDescriber;
|
||||
private readonly IWebAuthnCredentialRepository _credentialRepository;
|
||||
private readonly IPasswordHasher<User> _passwordHasher;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
/// <summary>
|
||||
/// Instantiates a new <see cref="RotateUserAccountKeysCommand"/>
|
||||
@@ -45,7 +46,8 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
||||
IEmergencyAccessRepository emergencyAccessRepository, IOrganizationUserRepository organizationUserRepository,
|
||||
IDeviceRepository deviceRepository,
|
||||
IPasswordHasher<User> passwordHasher,
|
||||
IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository)
|
||||
IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository,
|
||||
IFeatureService featureService)
|
||||
{
|
||||
_userService = userService;
|
||||
_userRepository = userRepository;
|
||||
@@ -59,6 +61,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
||||
_identityErrorDescriber = errors;
|
||||
_credentialRepository = credentialRepository;
|
||||
_passwordHasher = passwordHasher;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -100,7 +103,15 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
||||
List<UpdateEncryptedDataForKeyRotation> saveEncryptedDataActions = new();
|
||||
if (model.Ciphers.Any())
|
||||
{
|
||||
saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation(user.Id, model.Ciphers));
|
||||
var useBulkResourceCreationService = _featureService.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation);
|
||||
if (useBulkResourceCreationService)
|
||||
{
|
||||
saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation_vNext(user.Id, model.Ciphers));
|
||||
}
|
||||
else
|
||||
{
|
||||
saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation(user.Id, model.Ciphers));
|
||||
}
|
||||
}
|
||||
|
||||
if (model.Folders.Any())
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{{#>FullHtmlLayout}}
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
|
||||
Verify your email to access this Bitwarden Send.
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
|
||||
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
Your verification code is: <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{Token}}</b>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
|
||||
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again.
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
|
||||
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
|
||||
<hr />
|
||||
{{TheDate}} at {{TheTime}} {{TimeZone}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{/FullHtmlLayout}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{#>BasicTextLayout}}
|
||||
Verify your email to access this Bitwarden Send.
|
||||
|
||||
Your verification code is: {{Token}}
|
||||
|
||||
This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again.
|
||||
|
||||
Date : {{TheDate}} at {{TheTime}} {{TimeZone}}
|
||||
{{/BasicTextLayout}}
|
||||
211
src/Core/MailTemplates/Handlebars/Layouts/ProviderFull.html.hbs
Normal file
211
src/Core/MailTemplates/Handlebars/Layouts/ProviderFull.html.hbs
Normal file
@@ -0,0 +1,211 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<title>Bitwarden</title>
|
||||
</head>
|
||||
|
||||
<body style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; height: 100%; line-height: 25px; width: 100% !important; margin: 0;" bgcolor="#f6f6f6">
|
||||
<style type="text/css">
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
line-height: 25px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-text-size-adjust: none;
|
||||
}
|
||||
|
||||
body * {
|
||||
margin: 0;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
line-height: 25px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-text-size-adjust: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-text-size-adjust: none;
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
line-height: 25px;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f6f6f6;
|
||||
}
|
||||
|
||||
/* Provider-specific styles */
|
||||
.provider-header {
|
||||
background-color: #175DDC;
|
||||
height: 84px;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.provider-content {
|
||||
border-left: 1px solid #e9e9e9;
|
||||
border-right: 1px solid #e9e9e9;
|
||||
border-bottom: 1px solid #e9e9e9;
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.container-table {
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 0 10px 0 !important;
|
||||
}
|
||||
|
||||
.content-wrap {
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
.invoice {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.main {
|
||||
border-right: none !important;
|
||||
border-left: none !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.provider-header {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.provider-content {
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
padding-top: 10px !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 10px !important;
|
||||
}
|
||||
|
||||
.indented {
|
||||
padding-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 600px) {
|
||||
{{! Fix for Apple Mail }}
|
||||
.content-table {
|
||||
width: 600px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Component styling - these are explicitly applied via classes so that they can be
|
||||
gradually introduced as we update templates.*/
|
||||
a.inline-link {
|
||||
font-weight: bold;
|
||||
color: #175DDC;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
br.line-break {
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
color: #333;
|
||||
line-height: 25px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-text-size-adjust: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
{{! Yahoo center fix }}
|
||||
<table width="100%" cellpadding="0" cellspacing="0" bgcolor="#f6f6f6">
|
||||
<tr>
|
||||
<td class="container" width="100%" align="center">
|
||||
{{! 600px container }}
|
||||
<table cellpadding="0" cellspacing="0" width="100%" class="content-table">
|
||||
<tr>
|
||||
<td></td> {{! Left column (center fix) }}
|
||||
<td class="content" align="center" valign="top" width="660" style="padding-bottom: 20px;">
|
||||
<!-- Blue Header with Logo -->
|
||||
<table class="provider-header" cellpadding="0" cellspacing="0" width="660" bgcolor="#175DDC" style="background-color: #175DDC; width: 660px; height: 84px; opacity: 1; border-top-left-radius: 4px; border-top-right-radius: 4px;">
|
||||
<tr>
|
||||
<td valign="top" style="height: 20.53px; width: 417px; padding-left: 32px; padding-top: 32px;">
|
||||
<img src="https://assets.bitwarden.com/email/v1/logo-horizontal-white.png" alt="Bitwarden" style="display: block; opacity: 1; width: auto; height: 28px; max-width: 417px;" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Main Content Container -->
|
||||
<table class="main provider-content" cellpadding="0" cellspacing="0" width="660" style="width: 660px; border-left: 1px solid #e9e9e9; border-right: 1px solid #e9e9e9; border-bottom: 1px solid #e9e9e9; border-bottom-left-radius: 3px; border-bottom-right-radius: 3px;" bgcolor="white">
|
||||
<tr>
|
||||
<td class="content-wrap" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 20px; -webkit-text-size-adjust: none;" valign="top">
|
||||
|
||||
{{>@partial-block}}
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table class="footer" cellpadding="0" cellspacing="0" width="100%" style="margin: 0; width: 100%;">
|
||||
<tr>
|
||||
<td class="aligncenter social-icons" align="center" style="margin: 0; padding: 15px 0 0 0;" valign="top">
|
||||
<table cellpadding="0" cellspacing="0" style="margin: 0 auto;">
|
||||
<tr>
|
||||
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://x.com/bitwarden" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-x.png" alt="X" width="30" height="30" /></a></td>
|
||||
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.reddit.com/r/Bitwarden/" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-reddit.png" alt="Reddit" width="30" height="30" /></a></td>
|
||||
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://community.bitwarden.com/" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-discourse.png" alt="CommunityForums" width="30" height="30" /></a></td>
|
||||
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://github.com/bitwarden" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-github.png" alt="GitHub" width="30" height="30" /></a></td>
|
||||
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-youtube.png" alt="Youtube" width="30" height="30" /></a></td>
|
||||
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.linkedin.com/company/bitwarden1/" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" alt="LinkedIn" width="30" height="30" /></a></td>
|
||||
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.facebook.com/bitwarden/" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-facebook.png" alt="Facebook" width="30" height="30" /></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="content-block" style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 12px; font-weight: 400; color: #666666; line-height: 16px; letter-spacing: 0px; margin: 0; -webkit-font-smoothing: antialiased; padding: 15px 0 0 0; -webkit-text-size-adjust: none; text-align: center;" valign="top">
|
||||
© {{CurrentYear}} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="content-block" style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 12px; font-weight: 400; color: #999999; line-height: 16px; letter-spacing: 0px; margin: 0; -webkit-font-smoothing: antialiased; padding: 10px 0 0 0; -webkit-text-size-adjust: none; text-align: center;" valign="top">
|
||||
Always confirm you are on an official Bitwarden domain before logging in:<br/>
|
||||
<a href="#" style="color: #175DDC; text-decoration: none; font-weight: 700;">bitwarden.com</a> | <a href="#" style="color: #175DDC; text-decoration: none; font-weight: 700;">Learn why we include this</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td></td> {{! Right column (center fix) }}
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
{{#>ProviderFull}}
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #1B2029; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #1B2029; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 20px; -webkit-text-size-adjust: none;" valign="top">
|
||||
{{#if (eq CollectionMethod "send_invoice")}}
|
||||
<div style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 600; font-size: 24px; line-height: 32px; letter-spacing: 0px; color: #1B2029; margin: 0 0 8px 0;">Your subscription will renew soon</div>
|
||||
<div style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 24px; letter-spacing: 0px; color: #1B2029; margin: 0;">On <strong>{{date DueDate 'MMMM dd, yyyy'}}</strong> we'll send you an invoice with a summary of the charges including tax.</div>
|
||||
{{else}}
|
||||
<div style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 600; font-size: 24px; line-height: 32px; letter-spacing: 0px; color: #1B2029; margin: 0 0 8px 0;">Your subscription will renew on {{date DueDate 'MMMM dd, yyyy'}}</div>
|
||||
{{#if HasPaymentMethod}}
|
||||
<div style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 24px; letter-spacing: 0px; color: #1B2029; margin: 0;">To avoid any interruption in service, please ensure your {{PaymentMethodDescription}} can be charged for the following amount:</div>
|
||||
{{else}}
|
||||
<div style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 24px; letter-spacing: 0px; color: #1B2029; margin: 0;">To avoid any interruption in service, please add a payment method that can be charged for the following amount:</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
{{#unless (eq CollectionMethod "send_invoice")}}
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #1B2029; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 32px; font-weight: bold; color: #1B2029; line-height: 1.2; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 20px; -webkit-text-size-adjust: none;" valign="top">
|
||||
{{usd AmountDue}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/unless}}
|
||||
{{#if Items}}
|
||||
{{#unless (eq CollectionMethod "send_invoice")}}
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #1B2029; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; font-weight: 400; color: #1B2029; line-height: 24px; letter-spacing: 0px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
|
||||
<strong style="margin: 0; font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; font-weight: 700; color: ##1B2029; line-height: 24px; letter-spacing: 0px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Summary Of Charges</strong><br />
|
||||
<div style="border-bottom: 1px solid #ddd; margin: 5px 0 10px 0; padding-bottom: 5px;"></div>
|
||||
{{#each Items}}
|
||||
<div style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 24px; letter-spacing: 0px; color: #1B2029; margin: 0;">{{this}}</div>
|
||||
{{/each}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 20px; -webkit-text-size-adjust: none;" valign="top">
|
||||
{{#if (eq CollectionMethod "send_invoice")}}
|
||||
<div style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 24px; letter-spacing: 0px; color: #1B2029; margin: 0;">To avoid any interruption in service for you or your clients, please pay the invoice by the due date, or contact Bitwarden Customer Support to sign up for auto-pay.</div>
|
||||
{{else}}
|
||||
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
{{#unless (eq CollectionMethod "send_invoice")}}
|
||||
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
|
||||
<table cellpadding="0" cellspacing="0" style="margin: 0;">
|
||||
<tr>
|
||||
<td style="background-color: #175DDC; border-radius: 25px; padding: 12px 24px;">
|
||||
<a href="{{{UpdateBillingInfoUrl}}}" style="color: #ffffff; text-decoration: none; font-weight: 500; font-size: 16px;">Update payment method</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{{/unless}}
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 16px; -webkit-text-size-adjust: none;" valign="top">
|
||||
{{#if (eq CollectionMethod "send_invoice")}}
|
||||
<table cellpadding="0" cellspacing="0" style="margin: 0;">
|
||||
<tr>
|
||||
<td style="background-color: #175DDC; border-radius: 25px; padding: 12px 24px;">
|
||||
<a href="{{{ContactUrl}}}" style="color: #ffffff; text-decoration: none; font-weight: 500; font-size: 16px;">Contact Bitwarden Support</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
{{#if (eq CollectionMethod "send_invoice")}}
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block last" style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; font-weight: 400; color: #1B2029; line-height: 20px; letter-spacing: 0px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
|
||||
For assistance managing your subscription, please visit <a href="https://bitwarden.com/help/update-billing-info" style="color: #175DDC !important; text-decoration: none; font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 700; line-height: 20px; letter-spacing: 0px;"><strong style="color: #175DDC !important;">the Help Center</strong></a> or <a href="https://bitwarden.com/contact/" style="color: #175DDC !important; text-decoration: none; font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 700; line-height: 20px; letter-spacing: 0px;"><strong style="color: #175DDC !important;">contact Bitwarden Customer Support</strong></a>.
|
||||
</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
{{#unless (eq CollectionMethod "send_invoice")}}
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block last" style="font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; font-weight: 400; color: #1B2029; line-height: 20px; letter-spacing: 0px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
|
||||
For assistance managing your subscription, please visit <a href="https://bitwarden.com/help/update-billing-info" style="color: #175DDC !important; text-decoration: none; font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 700; line-height: 20px; letter-spacing: 0px;"><strong style="color: #175DDC !important;">the Help Center</strong></a> or <a href="https://bitwarden.com/contact/" style="color: #175DDC !important; text-decoration: none; font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 700; line-height: 20px; letter-spacing: 0px;"><strong style="color: #175DDC !important;">contact Bitwarden Customer Support</strong></a>.
|
||||
</td>
|
||||
</tr>
|
||||
{{/unless}}
|
||||
</table>
|
||||
{{/ProviderFull}}
|
||||
@@ -0,0 +1,41 @@
|
||||
{{#>BasicTextLayout}}
|
||||
{{#if (eq CollectionMethod "send_invoice")}}
|
||||
Your subscription will renew soon
|
||||
|
||||
On {{date DueDate 'MMMM dd, yyyy'}} we'll send you an invoice with a summary of the charges including tax.
|
||||
{{else}}
|
||||
Your subscription will renew on {{date DueDate 'MMMM dd, yyyy'}}
|
||||
|
||||
{{#if HasPaymentMethod}}
|
||||
To avoid any interruption in service, please ensure your {{PaymentMethodDescription}} can be charged for the following amount:
|
||||
{{else}}
|
||||
To avoid any interruption in service, please add a payment method that can be charged for the following amount:
|
||||
{{/if}}
|
||||
|
||||
{{usd AmountDue}}
|
||||
{{/if}}
|
||||
{{#if Items}}
|
||||
{{#unless (eq CollectionMethod "send_invoice")}}
|
||||
|
||||
Summary Of Charges
|
||||
------------------
|
||||
{{#each Items}}
|
||||
{{this}}
|
||||
{{/each}}
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
|
||||
{{#if (eq CollectionMethod "send_invoice")}}
|
||||
To avoid any interruption in service for you or your clients, please pay the invoice by the due date, or contact Bitwarden Customer Support to sign up for auto-pay.
|
||||
|
||||
Contact Bitwarden Support: {{{ContactUrl}}}
|
||||
|
||||
For assistance managing your subscription, please visit the **Help center** (https://bitwarden.com/help/update-billing-info) or **contact Bitwarden Customer Support** (https://bitwarden.com/contact/).
|
||||
{{else}}
|
||||
|
||||
{{/if}}
|
||||
|
||||
{{#unless (eq CollectionMethod "send_invoice")}}
|
||||
For assistance managing your subscription, please visit the **Help center** (https://bitwarden.com/help/update-billing-info) or **contact Bitwarden Customer Support** (https://bitwarden.com/contact/).
|
||||
{{/unless}}
|
||||
{{/BasicTextLayout}}
|
||||
@@ -13,5 +13,5 @@ public class TaxInfo
|
||||
public string BillingAddressCity { get; set; }
|
||||
public string BillingAddressState { get; set; }
|
||||
public string BillingAddressPostalCode { get; set; }
|
||||
public string BillingAddressCountry { get; set; } = "US";
|
||||
public string BillingAddressCountry { get; set; } = Constants.CountryAbbreviations.UnitedStates;
|
||||
}
|
||||
|
||||
12
src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs
Normal file
12
src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Bit.Core.Models.Mail.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Send email OTP view model
|
||||
/// </summary>
|
||||
public class DefaultEmailOtpViewModel : BaseMailModel
|
||||
{
|
||||
public string? Token { get; set; }
|
||||
public string? TheDate { get; set; }
|
||||
public string? TheTime { get; set; }
|
||||
public string? TimeZone { get; set; }
|
||||
}
|
||||
@@ -10,4 +10,9 @@ public class InvoiceUpcomingViewModel : BaseMailModel
|
||||
public List<string> Items { get; set; }
|
||||
public bool MentionInvoices { get; set; }
|
||||
public string UpdateBillingInfoUrl { get; set; } = "https://bitwarden.com/help/update-billing-info/";
|
||||
public string CollectionMethod { get; set; }
|
||||
public bool HasPaymentMethod { get; set; }
|
||||
public string PaymentMethodDescription { get; set; }
|
||||
public string HelpUrl { get; set; } = "https://bitwarden.com/help/";
|
||||
public string ContactUrl { get; set; } = "https://bitwarden.com/contact/";
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ public interface IMailService
|
||||
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
|
||||
Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
|
||||
Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose);
|
||||
Task SendSendEmailOtpEmailAsync(string email, string token, string subject);
|
||||
Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType type, DateTime utcNow, string ip);
|
||||
Task SendNoMasterPasswordHintEmailAsync(string email);
|
||||
Task SendMasterPasswordHintEmailAsync(string email, string hint);
|
||||
@@ -58,6 +59,14 @@ public interface IMailService
|
||||
DateTime dueDate,
|
||||
List<string> items,
|
||||
bool mentionInvoices);
|
||||
Task SendProviderInvoiceUpcoming(
|
||||
IEnumerable<string> emails,
|
||||
decimal amount,
|
||||
DateTime dueDate,
|
||||
List<string> items,
|
||||
string? collectionMethod,
|
||||
bool hasPaymentMethod,
|
||||
string? paymentMethodDescription);
|
||||
Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices);
|
||||
Task SendAddedCreditAsync(string email, decimal amount);
|
||||
Task SendLicenseExpiredAsync(IEnumerable<string> emails, string? organizationName = null);
|
||||
|
||||
@@ -90,9 +90,6 @@ public interface IUserService
|
||||
|
||||
void SetTwoFactorProvider(User user, TwoFactorProviderType type, bool setEnabled = true);
|
||||
|
||||
[Obsolete("To be removed when the feature flag pm-17128-recovery-code-login is removed PM-18175.")]
|
||||
Task<bool> RecoverTwoFactorAsync(string email, string masterPassword, string recoveryCode);
|
||||
|
||||
/// <summary>
|
||||
/// This method is used by the TwoFactorAuthenticationValidator to recover two
|
||||
/// factor for a user. This allows users to be logged in after a successful recovery
|
||||
|
||||
@@ -15,6 +15,7 @@ using Bit.Core.Billing.Models.Mail;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Models.Mail;
|
||||
using Bit.Core.Models.Mail.Auth;
|
||||
using Bit.Core.Models.Mail.Billing;
|
||||
using Bit.Core.Models.Mail.FamiliesForEnterprise;
|
||||
using Bit.Core.Models.Mail.Provider;
|
||||
@@ -199,6 +200,26 @@ public class HandlebarsMailService : IMailService
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
}
|
||||
|
||||
public async Task SendSendEmailOtpEmailAsync(string email, string token, string subject)
|
||||
{
|
||||
var message = CreateDefaultMessage(subject, email);
|
||||
var requestDateTime = DateTime.UtcNow;
|
||||
var model = new DefaultEmailOtpViewModel
|
||||
{
|
||||
Token = token,
|
||||
TheDate = requestDateTime.ToLongDateString(),
|
||||
TheTime = requestDateTime.ToShortTimeString(),
|
||||
TimeZone = _utcTimeZoneDisplay,
|
||||
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
|
||||
SiteName = _globalSettings.SiteName,
|
||||
};
|
||||
await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmail", model);
|
||||
message.MetaData.Add("SendGridBypassListManagement", true);
|
||||
// TODO - PM-25380 change to string constant
|
||||
message.Category = "SendEmailOtp";
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
}
|
||||
|
||||
public async Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
|
||||
{
|
||||
// Check if we've sent this email within the last hour
|
||||
@@ -457,6 +478,33 @@ public class HandlebarsMailService : IMailService
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
}
|
||||
|
||||
public async Task SendProviderInvoiceUpcoming(
|
||||
IEnumerable<string> emails,
|
||||
decimal amount,
|
||||
DateTime dueDate,
|
||||
List<string> items,
|
||||
string? collectionMethod = null,
|
||||
bool hasPaymentMethod = true,
|
||||
string? paymentMethodDescription = null)
|
||||
{
|
||||
var message = CreateDefaultMessage("Your upcoming Bitwarden invoice", emails);
|
||||
var model = new InvoiceUpcomingViewModel
|
||||
{
|
||||
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
|
||||
SiteName = _globalSettings.SiteName,
|
||||
AmountDue = amount,
|
||||
DueDate = dueDate,
|
||||
Items = items,
|
||||
MentionInvoices = false,
|
||||
CollectionMethod = collectionMethod,
|
||||
HasPaymentMethod = hasPaymentMethod,
|
||||
PaymentMethodDescription = paymentMethodDescription
|
||||
};
|
||||
await AddMessageContentAsync(message, "ProviderInvoiceUpcoming", model);
|
||||
message.Category = "ProviderInvoiceUpcoming";
|
||||
await _mailDeliveryService.SendEmailAsync(message);
|
||||
}
|
||||
|
||||
public async Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices)
|
||||
{
|
||||
var message = CreateDefaultMessage("Payment Failed", email);
|
||||
@@ -538,7 +586,7 @@ public class HandlebarsMailService : IMailService
|
||||
SiteName = _globalSettings.SiteName,
|
||||
DeviceType = deviceType,
|
||||
TheDate = timestamp.ToLongDateString(),
|
||||
TheTime = timestamp.ToShortTimeString(),
|
||||
TheTime = timestamp.ToString("hh:mm:ss tt"),
|
||||
TimeZone = _utcTimeZoneDisplay,
|
||||
IpAddress = ip
|
||||
};
|
||||
@@ -687,6 +735,8 @@ public class HandlebarsMailService : IMailService
|
||||
Handlebars.RegisterTemplate("SecurityTasksHtmlLayout", securityTasksHtmlLayoutSource);
|
||||
var securityTasksTextLayoutSource = await ReadSourceAsync("Layouts.SecurityTasks.text");
|
||||
Handlebars.RegisterTemplate("SecurityTasksTextLayout", securityTasksTextLayoutSource);
|
||||
var providerFullHtmlLayoutSource = await ReadSourceAsync("Layouts.ProviderFull.html");
|
||||
Handlebars.RegisterTemplate("ProviderFull", providerFullHtmlLayoutSource);
|
||||
|
||||
Handlebars.RegisterHelper("date", (writer, context, parameters) =>
|
||||
{
|
||||
@@ -842,6 +892,19 @@ public class HandlebarsMailService : IMailService
|
||||
writer.WriteSafeString(string.Empty);
|
||||
}
|
||||
});
|
||||
|
||||
// Equality comparison helper for conditional templates.
|
||||
Handlebars.RegisterHelper("eq", (context, arguments) =>
|
||||
{
|
||||
if (arguments.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var value1 = arguments[0]?.ToString();
|
||||
var value2 = arguments[1]?.ToString();
|
||||
return string.Equals(value1, value2, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task SendEmergencyAccessInviteEmailAsync(EmergencyAccess emergencyAccess, string name, string token)
|
||||
|
||||
@@ -9,11 +9,9 @@ using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Tax.Models;
|
||||
using Bit.Core.Billing.Tax.Requests;
|
||||
using Bit.Core.Billing.Tax.Responses;
|
||||
using Bit.Core.Billing.Tax.Services;
|
||||
using Bit.Core.Billing.Tax.Services.Implementations;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@@ -21,7 +19,6 @@ using Bit.Core.Models.BitStripe;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
using PaymentMethod = Stripe.PaymentMethod;
|
||||
@@ -41,8 +38,6 @@ public class StripePaymentService : IPaymentService
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ITaxService _taxService;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
private readonly IAutomaticTaxFactory _automaticTaxFactory;
|
||||
private readonly IAutomaticTaxStrategy _personalUseTaxStrategy;
|
||||
|
||||
public StripePaymentService(
|
||||
ITransactionRepository transactionRepository,
|
||||
@@ -52,9 +47,7 @@ public class StripePaymentService : IPaymentService
|
||||
IGlobalSettings globalSettings,
|
||||
IFeatureService featureService,
|
||||
ITaxService taxService,
|
||||
IPricingClient pricingClient,
|
||||
IAutomaticTaxFactory automaticTaxFactory,
|
||||
[FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy personalUseTaxStrategy)
|
||||
IPricingClient pricingClient)
|
||||
{
|
||||
_transactionRepository = transactionRepository;
|
||||
_logger = logger;
|
||||
@@ -64,8 +57,6 @@ public class StripePaymentService : IPaymentService
|
||||
_featureService = featureService;
|
||||
_taxService = taxService;
|
||||
_pricingClient = pricingClient;
|
||||
_automaticTaxFactory = automaticTaxFactory;
|
||||
_personalUseTaxStrategy = personalUseTaxStrategy;
|
||||
}
|
||||
|
||||
private async Task ChangeOrganizationSponsorship(
|
||||
@@ -137,7 +128,7 @@ public class StripePaymentService : IPaymentService
|
||||
{
|
||||
if (sub.Customer is
|
||||
{
|
||||
Address.Country: not "US",
|
||||
Address.Country: not Constants.CountryAbbreviations.UnitedStates,
|
||||
TaxExempt: not StripeConstants.TaxExempt.Reverse
|
||||
})
|
||||
{
|
||||
@@ -987,8 +978,6 @@ public class StripePaymentService : IPaymentService
|
||||
}
|
||||
}
|
||||
|
||||
_personalUseTaxStrategy.SetInvoiceCreatePreviewOptions(options);
|
||||
|
||||
try
|
||||
{
|
||||
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options);
|
||||
@@ -1152,9 +1141,12 @@ public class StripePaymentService : IPaymentService
|
||||
}
|
||||
}
|
||||
|
||||
var automaticTaxFactoryParameters = new AutomaticTaxFactoryParameters(parameters.PasswordManager.Plan);
|
||||
var automaticTaxStrategy = await _automaticTaxFactory.CreateAsync(automaticTaxFactoryParameters);
|
||||
automaticTaxStrategy.SetInvoiceCreatePreviewOptions(options);
|
||||
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
|
||||
if (parameters.PasswordManager.Plan.IsBusinessProductTierType() &&
|
||||
parameters.TaxInformation.Country != Constants.CountryAbbreviations.UnitedStates)
|
||||
{
|
||||
options.CustomerDetails.TaxExempt = StripeConstants.TaxExempt.Reverse;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -865,39 +865,6 @@ public class UserService : UserManager<User>, IUserService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// To be removed when the feature flag pm-17128-recovery-code-login is removed PM-18175.
|
||||
/// </summary>
|
||||
[Obsolete("Two Factor recovery is handled in the TwoFactorAuthenticationValidator.")]
|
||||
public async Task<bool> RecoverTwoFactorAsync(string email, string secret, string recoveryCode)
|
||||
{
|
||||
var user = await _userRepository.GetByEmailAsync(email);
|
||||
if (user == null)
|
||||
{
|
||||
// No user exists. Do we want to send an email telling them this in the future?
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!await VerifySecretAsync(user, secret))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!CoreHelpers.FixedTimeEquals(user.TwoFactorRecoveryCode, recoveryCode))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
user.TwoFactorProviders = null;
|
||||
user.TwoFactorRecoveryCode = CoreHelpers.SecureRandomString(32, upper: false, special: false);
|
||||
await SaveUserAsync(user);
|
||||
await _mailService.SendRecoverTwoFactorEmail(user.Email, DateTime.UtcNow, _currentContext.IpAddress);
|
||||
await _eventService.LogUserEventAsync(user.Id, EventType.User_Recovered2fa);
|
||||
await CheckPoliciesOnTwoFactorRemovalAsync(user);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> RecoverTwoFactorAsync(User user, string recoveryCode)
|
||||
{
|
||||
if (!CoreHelpers.FixedTimeEquals(
|
||||
|
||||
@@ -93,6 +93,11 @@ public class NoopMailService : IMailService
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task SendSendEmailOtpEmailAsync(string email, string token, string subject)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
@@ -132,6 +137,15 @@ public class NoopMailService : IMailService
|
||||
List<string> items,
|
||||
bool mentionInvoices) => Task.FromResult(0);
|
||||
|
||||
public Task SendProviderInvoiceUpcoming(
|
||||
IEnumerable<string> emails,
|
||||
decimal amount,
|
||||
DateTime dueDate,
|
||||
List<string> items,
|
||||
string? collectionMethod = null,
|
||||
bool hasPaymentMethod = true,
|
||||
string? paymentMethodDescription = null) => Task.FromResult(0);
|
||||
|
||||
public Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
|
||||
@@ -108,7 +108,15 @@ public class ImportCiphersCommand : IImportCiphersCommand
|
||||
}
|
||||
|
||||
// Create it all
|
||||
await _cipherRepository.CreateAsync(importingUserId, ciphers, newFolders);
|
||||
var useBulkResourceCreationService = _featureService.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation);
|
||||
if (useBulkResourceCreationService)
|
||||
{
|
||||
await _cipherRepository.CreateAsync_vNext(importingUserId, ciphers, newFolders);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _cipherRepository.CreateAsync(importingUserId, ciphers, newFolders);
|
||||
}
|
||||
|
||||
// push
|
||||
await _pushService.PushSyncVaultAsync(importingUserId);
|
||||
@@ -183,7 +191,15 @@ public class ImportCiphersCommand : IImportCiphersCommand
|
||||
}
|
||||
|
||||
// Create it all
|
||||
await _cipherRepository.CreateAsync(ciphers, newCollections, collectionCiphers, newCollectionUsers);
|
||||
var useBulkResourceCreationService = _featureService.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation);
|
||||
if (useBulkResourceCreationService)
|
||||
{
|
||||
await _cipherRepository.CreateAsync_vNext(ciphers, newCollections, collectionCiphers, newCollectionUsers);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _cipherRepository.CreateAsync(ciphers, newCollections, collectionCiphers, newCollectionUsers);
|
||||
}
|
||||
|
||||
// push
|
||||
await _pushService.PushSyncVaultAsync(importingUserId);
|
||||
|
||||
@@ -32,12 +32,28 @@ public interface ICipherRepository : IRepository<Cipher, Guid>
|
||||
Task DeleteByUserIdAsync(Guid userId);
|
||||
Task DeleteByOrganizationIdAsync(Guid organizationId);
|
||||
Task UpdateCiphersAsync(Guid userId, IEnumerable<Cipher> ciphers);
|
||||
/// <inheritdoc cref="UpdateCiphersAsync(Guid, IEnumerable{Cipher})"/>
|
||||
/// <remarks>
|
||||
/// This version uses the bulk resource creation service to create the temp table.
|
||||
/// </remarks>
|
||||
Task UpdateCiphersAsync_vNext(Guid userId, IEnumerable<Cipher> ciphers);
|
||||
/// <summary>
|
||||
/// Create ciphers and folders for the specified UserId. Must not be used to create organization owned items.
|
||||
/// </summary>
|
||||
Task CreateAsync(Guid userId, IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders);
|
||||
/// <inheritdoc cref="CreateAsync(Guid, IEnumerable{Cipher}, IEnumerable{Folder})"/>
|
||||
/// <remarks>
|
||||
/// This version uses the bulk resource creation service to create the temp tables.
|
||||
/// </remarks>
|
||||
Task CreateAsync_vNext(Guid userId, IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders);
|
||||
Task CreateAsync(IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections,
|
||||
IEnumerable<CollectionCipher> collectionCiphers, IEnumerable<CollectionUser> collectionUsers);
|
||||
/// <inheritdoc cref="CreateAsync(IEnumerable{Cipher}, IEnumerable{Collection}, IEnumerable{CollectionCipher}, IEnumerable{CollectionUser})"/>
|
||||
/// <remarks>
|
||||
/// This version uses the bulk resource creation service to create the temp tables.
|
||||
/// </remarks>
|
||||
Task CreateAsync_vNext(IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections,
|
||||
IEnumerable<CollectionCipher> collectionCiphers, IEnumerable<CollectionUser> collectionUsers);
|
||||
Task SoftDeleteAsync(IEnumerable<Guid> ids, Guid userId);
|
||||
Task SoftDeleteByIdsOrganizationIdAsync(IEnumerable<Guid> ids, Guid organizationId);
|
||||
Task<DateTime> RestoreAsync(IEnumerable<Guid> ids, Guid userId);
|
||||
@@ -68,4 +84,10 @@ public interface ICipherRepository : IRepository<Cipher, Guid>
|
||||
/// <param name="ciphers">A list of ciphers with updated data</param>
|
||||
UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid userId,
|
||||
IEnumerable<Cipher> ciphers);
|
||||
/// <inheritdoc cref="UpdateForKeyRotation(Guid, IEnumerable{Cipher})"/>
|
||||
/// <remarks>
|
||||
/// This version uses the bulk resource creation service to create the temp table.
|
||||
/// </remarks>
|
||||
UpdateEncryptedDataForKeyRotation UpdateForKeyRotation_vNext(Guid userId,
|
||||
IEnumerable<Cipher> ciphers);
|
||||
}
|
||||
|
||||
@@ -642,7 +642,15 @@ public class CipherService : ICipherService
|
||||
cipherIds.Add(cipher.Id);
|
||||
}
|
||||
|
||||
await _cipherRepository.UpdateCiphersAsync(sharingUserId, cipherInfos.Select(c => c.cipher));
|
||||
var useBulkResourceCreationService = _featureService.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation);
|
||||
if (useBulkResourceCreationService)
|
||||
{
|
||||
await _cipherRepository.UpdateCiphersAsync_vNext(sharingUserId, cipherInfos.Select(c => c.cipher));
|
||||
}
|
||||
else
|
||||
{
|
||||
await _cipherRepository.UpdateCiphersAsync(sharingUserId, cipherInfos.Select(c => c.cipher));
|
||||
}
|
||||
await _collectionCipherRepository.UpdateCollectionsForCiphersAsync(cipherIds, sharingUserId,
|
||||
organizationId, collectionIds);
|
||||
|
||||
|
||||
@@ -6,12 +6,18 @@ namespace Bit.Identity.Controllers;
|
||||
public class InfoController : Controller
|
||||
{
|
||||
[HttpGet("~/alive")]
|
||||
[HttpGet("~/now")]
|
||||
public DateTime GetAlive()
|
||||
{
|
||||
return DateTime.UtcNow;
|
||||
}
|
||||
|
||||
[HttpGet("~/now")]
|
||||
[Obsolete("This endpoint is deprecated. Use GET /alive instead.")]
|
||||
public DateTime GetNow()
|
||||
{
|
||||
return GetAlive();
|
||||
}
|
||||
|
||||
[HttpGet("~/version")]
|
||||
public JsonResult GetVersion()
|
||||
{
|
||||
|
||||
@@ -29,7 +29,7 @@ public class ApiResources
|
||||
}),
|
||||
new(ApiScopes.ApiSendAccess, [
|
||||
JwtClaimTypes.Subject,
|
||||
Claims.SendId
|
||||
Claims.SendAccessClaims.SendId
|
||||
]),
|
||||
new(ApiScopes.Internal, new[] { JwtClaimTypes.Subject }),
|
||||
new(ApiScopes.ApiPush, new[] { JwtClaimTypes.Subject }),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Duende.IdentityServer.Validation;
|
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||
|
||||
public interface ISendAuthenticationMethodValidator<T> where T : SendAuthenticationMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// </summary>
|
||||
/// <param name="context">request context</param>
|
||||
/// <param name="authMethod">SendAuthenticationRecord that contains the information to be compared against the context</param>
|
||||
/// <param name="sendId">the sendId being accessed</param>
|
||||
/// <returns>returns the result of the validation; A failed result will be an error a successful will contain the claims and a success</returns>
|
||||
Task<GrantValidationResult> ValidateRequestAsync(ExtensionGrantValidationContext context, T authMethod, Guid sendId);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Duende.IdentityServer.Validation;
|
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||
|
||||
public interface ISendPasswordRequestValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the send password hash against the client hashed password.
|
||||
/// If this method fails then it will automatically set the context.Result to an invalid grant result.
|
||||
/// </summary>
|
||||
/// <param name="context">request context</param>
|
||||
/// <param name="resourcePassword">resource password authentication method containing the hash of the Send being retrieved</param>
|
||||
/// <returns>returns the result of the validation; A failed result will be an error a successful will contain the claims and a success</returns>
|
||||
GrantValidationResult ValidateSendPassword(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Duende.IdentityServer.Validation;
|
||||
using Bit.Core.Auth.Identity.TokenProviders;
|
||||
using Duende.IdentityServer.Validation;
|
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||
|
||||
@@ -34,7 +35,7 @@ public static class SendAccessConstants
|
||||
public static class GrantValidatorResults
|
||||
{
|
||||
/// <summary>
|
||||
/// The sendId is valid and the request is well formed.
|
||||
/// The sendId is valid and the request is well formed. Not returned in any response.
|
||||
/// </summary>
|
||||
public const string ValidSendGuid = "valid_send_guid";
|
||||
/// <summary>
|
||||
@@ -66,8 +67,40 @@ public static class SendAccessConstants
|
||||
/// </summary>
|
||||
public const string EmailRequired = "email_required";
|
||||
/// <summary>
|
||||
/// Represents the error code indicating that an email address is invalid.
|
||||
/// </summary>
|
||||
public const string EmailInvalid = "email_invalid";
|
||||
/// <summary>
|
||||
/// Represents the status indicating that both email and OTP are required, and the OTP has been sent.
|
||||
/// </summary>
|
||||
public const string EmailOtpSent = "email_and_otp_required_otp_sent";
|
||||
/// <summary>
|
||||
/// Represents the status indicating that both email and OTP are required, and the OTP is invalid.
|
||||
/// </summary>
|
||||
public const string EmailOtpInvalid = "otp_invalid";
|
||||
/// <summary>
|
||||
/// For what ever reason the OTP was not able to be generated
|
||||
/// </summary>
|
||||
public const string OtpGenerationFailed = "otp_generation_failed";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// These are the constants for the OTP token that is generated during the email otp authentication process.
|
||||
/// These items are required by <see cref="IOtpTokenProvider{TOptions}"/> to aid in the creation of a unique lookup key.
|
||||
/// Look up key format is: {TokenProviderName}_{Purpose}_{TokenUniqueIdentifier}
|
||||
/// </summary>
|
||||
public static class OtpToken
|
||||
{
|
||||
public const string TokenProviderName = "send_access";
|
||||
public const string Purpose = "email_otp";
|
||||
/// <summary>
|
||||
/// This will be send_id {0} and email {1}
|
||||
/// </summary>
|
||||
public const string TokenUniqueIdentifier = "{0}_{1}";
|
||||
}
|
||||
|
||||
public static class OtpEmail
|
||||
{
|
||||
public const string Subject = "Your Bitwarden Send verification code is {0}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||
|
||||
public class SendAccessGrantValidator(
|
||||
ISendAuthenticationQuery _sendAuthenticationQuery,
|
||||
ISendPasswordRequestValidator _sendPasswordRequestValidator,
|
||||
ISendAuthenticationMethodValidator<ResourcePassword> _sendPasswordRequestValidator,
|
||||
ISendAuthenticationMethodValidator<EmailOtp> _sendEmailOtpRequestValidator,
|
||||
IFeatureService _featureService)
|
||||
: IExtensionGrantValidator
|
||||
{
|
||||
@@ -61,16 +62,14 @@ public class SendAccessGrantValidator(
|
||||
// automatically issue access token
|
||||
context.Result = BuildBaseSuccessResult(sendIdGuid);
|
||||
return;
|
||||
|
||||
case ResourcePassword rp:
|
||||
// Validate if the password is correct, or if we need to respond with a 400 stating a password has is required
|
||||
context.Result = _sendPasswordRequestValidator.ValidateSendPassword(context, rp, sendIdGuid);
|
||||
// Validate if the password is correct, or if we need to respond with a 400 stating a password is invalid or required.
|
||||
context.Result = await _sendPasswordRequestValidator.ValidateRequestAsync(context, rp, sendIdGuid);
|
||||
return;
|
||||
case EmailOtp eo:
|
||||
// TODO PM-22678: We will either send the OTP here or validate it based on if otp exists in the request.
|
||||
// SendOtpToEmail(eo.Emails) or ValidateOtp(eo.Emails);
|
||||
// break;
|
||||
|
||||
// Validate if the request has the correct email and OTP. If not, respond with a 400 and information about the failure.
|
||||
context.Result = await _sendEmailOtpRequestValidator.ValidateRequestAsync(context, eo, sendIdGuid);
|
||||
return;
|
||||
default:
|
||||
// shouldn’t ever hit this
|
||||
throw new InvalidOperationException($"Unknown auth method: {method.GetType()}");
|
||||
@@ -114,28 +113,27 @@ public class SendAccessGrantValidator(
|
||||
/// <summary>
|
||||
/// Builds an error result for the specified error type.
|
||||
/// </summary>
|
||||
/// <param name="error">The error type.</param>
|
||||
/// <param name="error">This error is a constant string from <see cref="SendAccessConstants.GrantValidatorResults"/></param>
|
||||
/// <returns>The error result.</returns>
|
||||
private static GrantValidationResult BuildErrorResult(string error)
|
||||
{
|
||||
var customResponse = new Dictionary<string, object>
|
||||
{
|
||||
{ SendAccessConstants.SendAccessError, error }
|
||||
};
|
||||
|
||||
return error switch
|
||||
{
|
||||
// Request is the wrong shape
|
||||
SendAccessConstants.GrantValidatorResults.MissingSendId => new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidRequest,
|
||||
errorDescription: _sendGrantValidatorErrorDescriptions[SendAccessConstants.GrantValidatorResults.MissingSendId],
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ SendAccessConstants.SendAccessError, SendAccessConstants.GrantValidatorResults.MissingSendId}
|
||||
}),
|
||||
errorDescription: _sendGrantValidatorErrorDescriptions[error],
|
||||
customResponse),
|
||||
// Request is correct shape but data is bad
|
||||
SendAccessConstants.GrantValidatorResults.InvalidSendId => new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
errorDescription: _sendGrantValidatorErrorDescriptions[SendAccessConstants.GrantValidatorResults.InvalidSendId],
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ SendAccessConstants.SendAccessError, SendAccessConstants.GrantValidatorResults.InvalidSendId }
|
||||
}),
|
||||
errorDescription: _sendGrantValidatorErrorDescriptions[error],
|
||||
customResponse),
|
||||
// should never get here
|
||||
_ => new GrantValidationResult(TokenRequestErrors.InvalidRequest)
|
||||
};
|
||||
@@ -145,7 +143,7 @@ public class SendAccessGrantValidator(
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(Claims.SendId, sendId.ToString()),
|
||||
new(Claims.SendAccessClaims.SendId, sendId.ToString()),
|
||||
new(Claims.Type, IdentityClientType.Send.ToString())
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Core.Auth.Identity.TokenProviders;
|
||||
using Bit.Core.Identity;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Identity.IdentityServer.Enums;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Validation;
|
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||
|
||||
public class SendEmailOtpRequestValidator(
|
||||
IOtpTokenProvider<DefaultOtpTokenProviderOptions> otpTokenProvider,
|
||||
IMailService mailService) : ISendAuthenticationMethodValidator<EmailOtp>
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// static object that contains the error messages for the SendEmailOtpRequestValidator.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> _sendEmailOtpValidatorErrorDescriptions = new()
|
||||
{
|
||||
{ SendAccessConstants.EmailOtpValidatorResults.EmailRequired, $"{SendAccessConstants.TokenRequest.Email} is required." },
|
||||
{ SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent, "email otp sent." },
|
||||
{ SendAccessConstants.EmailOtpValidatorResults.EmailInvalid, $"{SendAccessConstants.TokenRequest.Email} is invalid." },
|
||||
{ SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid, $"{SendAccessConstants.TokenRequest.Email} otp is invalid." },
|
||||
};
|
||||
|
||||
public async Task<GrantValidationResult> ValidateRequestAsync(ExtensionGrantValidationContext context, EmailOtp authMethod, Guid sendId)
|
||||
{
|
||||
var request = context.Request.Raw;
|
||||
// get email
|
||||
var email = request.Get(SendAccessConstants.TokenRequest.Email);
|
||||
|
||||
// It is an invalid request if the email is missing which indicated bad shape.
|
||||
if (string.IsNullOrEmpty(email))
|
||||
{
|
||||
// Request is the wrong shape and doesn't contain an email field.
|
||||
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailRequired);
|
||||
}
|
||||
|
||||
// email must be in the list of emails in the EmailOtp array
|
||||
if (!authMethod.Emails.Contains(email))
|
||||
{
|
||||
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid);
|
||||
}
|
||||
|
||||
// get otp from request
|
||||
var requestOtp = request.Get(SendAccessConstants.TokenRequest.Otp);
|
||||
var uniqueIdentifierForTokenCache = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email);
|
||||
if (string.IsNullOrEmpty(requestOtp))
|
||||
{
|
||||
// Since the request doesn't have an OTP, generate one
|
||||
var token = await otpTokenProvider.GenerateTokenAsync(
|
||||
SendAccessConstants.OtpToken.TokenProviderName,
|
||||
SendAccessConstants.OtpToken.Purpose,
|
||||
uniqueIdentifierForTokenCache);
|
||||
|
||||
// Verify that the OTP is generated
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.OtpGenerationFailed);
|
||||
}
|
||||
|
||||
await mailService.SendSendEmailOtpEmailAsync(
|
||||
email,
|
||||
token,
|
||||
string.Format(SendAccessConstants.OtpEmail.Subject, token));
|
||||
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent);
|
||||
}
|
||||
|
||||
// validate request otp
|
||||
var otpResult = await otpTokenProvider.ValidateTokenAsync(
|
||||
requestOtp,
|
||||
SendAccessConstants.OtpToken.TokenProviderName,
|
||||
SendAccessConstants.OtpToken.Purpose,
|
||||
uniqueIdentifierForTokenCache);
|
||||
|
||||
// If OTP is invalid return error result
|
||||
if (!otpResult)
|
||||
{
|
||||
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid);
|
||||
}
|
||||
|
||||
return BuildSuccessResult(sendId, email!);
|
||||
}
|
||||
|
||||
private static GrantValidationResult BuildErrorResult(string error)
|
||||
{
|
||||
switch (error)
|
||||
{
|
||||
case SendAccessConstants.EmailOtpValidatorResults.EmailRequired:
|
||||
case SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent:
|
||||
return new GrantValidationResult(TokenRequestErrors.InvalidRequest,
|
||||
errorDescription: _sendEmailOtpValidatorErrorDescriptions[error],
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ SendAccessConstants.SendAccessError, error }
|
||||
});
|
||||
case SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid:
|
||||
case SendAccessConstants.EmailOtpValidatorResults.EmailInvalid:
|
||||
return new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
errorDescription: _sendEmailOtpValidatorErrorDescriptions[error],
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ SendAccessConstants.SendAccessError, error }
|
||||
});
|
||||
default:
|
||||
return new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidRequest,
|
||||
errorDescription: error);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a successful validation result for the Send password send_access grant.
|
||||
/// </summary>
|
||||
/// <param name="sendId">Guid of the send being accessed.</param>
|
||||
/// <returns>successful grant validation result</returns>
|
||||
private static GrantValidationResult BuildSuccessResult(Guid sendId, string email)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(Claims.SendAccessClaims.SendId, sendId.ToString()),
|
||||
new(Claims.SendAccessClaims.Email, email),
|
||||
new(Claims.Type, IdentityClientType.Send.ToString())
|
||||
};
|
||||
|
||||
return new GrantValidationResult(
|
||||
subject: sendId.ToString(),
|
||||
authenticationMethod: CustomGrantTypes.SendAccess,
|
||||
claims: claims);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ using Duende.IdentityServer.Validation;
|
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||
|
||||
public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher) : ISendPasswordRequestValidator
|
||||
public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher) : ISendAuthenticationMethodValidator<ResourcePassword>
|
||||
{
|
||||
private readonly ISendPasswordHasher _sendPasswordHasher = sendPasswordHasher;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
|
||||
{ SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired, $"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is required." }
|
||||
};
|
||||
|
||||
public GrantValidationResult ValidateSendPassword(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId)
|
||||
public Task<GrantValidationResult> ValidateRequestAsync(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId)
|
||||
{
|
||||
var request = context.Request.Raw;
|
||||
var clientHashedPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword);
|
||||
@@ -30,13 +30,13 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
|
||||
if (clientHashedPassword == null)
|
||||
{
|
||||
// Request is the wrong shape and doesn't contain a passwordHashB64 field.
|
||||
return new GrantValidationResult(
|
||||
return Task.FromResult(new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidRequest,
|
||||
errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired],
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired }
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// _sendPasswordHasher.PasswordHashMatches checks for an empty string so no need to do it before we make the call.
|
||||
@@ -46,16 +46,16 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
|
||||
if (!hashMatches)
|
||||
{
|
||||
// Request is the correct shape but the passwordHashB64 doesn't match, hash could be empty.
|
||||
return new GrantValidationResult(
|
||||
return Task.FromResult(new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant,
|
||||
errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch],
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch }
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
return BuildSendPasswordSuccessResult(sendId);
|
||||
return Task.FromResult(BuildSendPasswordSuccessResult(sendId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -67,7 +67,7 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(Claims.SendId, sendId.ToString()),
|
||||
new(Claims.SendAccessClaims.SendId, sendId.ToString()),
|
||||
new(Claims.Type, IdentityClientType.Send.ToString())
|
||||
};
|
||||
|
||||
|
||||
@@ -66,15 +66,7 @@ public class Startup
|
||||
|
||||
services.AddSwaggerGen(config =>
|
||||
{
|
||||
config.SchemaFilter<EnumSchemaFilter>();
|
||||
config.SchemaFilter<EncryptedStringSchemaFilter>();
|
||||
|
||||
// These two filters require debug symbols/git, so only add them in development mode
|
||||
if (Environment.IsDevelopment())
|
||||
{
|
||||
config.DocumentFilter<GitCommitDocumentFilter>();
|
||||
config.OperationFilter<SourceFileLineOperationFilter>();
|
||||
}
|
||||
config.InitializeSwaggerFilters(Environment);
|
||||
|
||||
config.SwaggerDoc("v1", new OpenApiInfo { Title = "Bitwarden Identity", Version = "v1" });
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.IdentityServer;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Identity.IdentityServer;
|
||||
using Bit.Identity.IdentityServer.ClientProviders;
|
||||
@@ -26,7 +27,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddTransient<IDeviceValidator, DeviceValidator>();
|
||||
services.AddTransient<ITwoFactorAuthenticationValidator, TwoFactorAuthenticationValidator>();
|
||||
services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>();
|
||||
services.AddTransient<ISendPasswordRequestValidator, SendPasswordRequestValidator>();
|
||||
services.AddTransient<ISendAuthenticationMethodValidator<ResourcePassword>, SendPasswordRequestValidator>();
|
||||
services.AddTransient<ISendAuthenticationMethodValidator<EmailOtp>, SendEmailOtpRequestValidator>();
|
||||
|
||||
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
|
||||
var identityServerBuilder = services
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Data;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Bit.Infrastructure.Dapper.AdminConsole.Helpers;
|
||||
@@ -15,6 +16,38 @@ public static class BulkResourceCreationService
|
||||
await bulkCopy.WriteToServerAsync(dataTable);
|
||||
}
|
||||
|
||||
public static async Task CreateCiphersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable<Cipher> ciphers, string errorMessage = _defaultErrorMessage)
|
||||
{
|
||||
using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction);
|
||||
bulkCopy.DestinationTableName = "[dbo].[Cipher]";
|
||||
var dataTable = BuildCiphersTable(bulkCopy, ciphers, errorMessage);
|
||||
await bulkCopy.WriteToServerAsync(dataTable);
|
||||
}
|
||||
|
||||
public static async Task CreateFoldersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable<Folder> folders, string errorMessage = _defaultErrorMessage)
|
||||
{
|
||||
using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction);
|
||||
bulkCopy.DestinationTableName = "[dbo].[Folder]";
|
||||
var dataTable = BuildFoldersTable(bulkCopy, folders, errorMessage);
|
||||
await bulkCopy.WriteToServerAsync(dataTable);
|
||||
}
|
||||
|
||||
public static async Task CreateCollectionCiphersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable<CollectionCipher> collectionCiphers, string errorMessage = _defaultErrorMessage)
|
||||
{
|
||||
using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction);
|
||||
bulkCopy.DestinationTableName = "[dbo].[CollectionCipher]";
|
||||
var dataTable = BuildCollectionCiphersTable(bulkCopy, collectionCiphers, errorMessage);
|
||||
await bulkCopy.WriteToServerAsync(dataTable);
|
||||
}
|
||||
|
||||
public static async Task CreateTempCiphersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable<Cipher> ciphers, string errorMessage = _defaultErrorMessage)
|
||||
{
|
||||
using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction);
|
||||
bulkCopy.DestinationTableName = "#TempCipher";
|
||||
var dataTable = BuildCiphersTable(bulkCopy, ciphers, errorMessage);
|
||||
await bulkCopy.WriteToServerAsync(dataTable);
|
||||
}
|
||||
|
||||
private static DataTable BuildCollectionsUsersTable(SqlBulkCopy bulkCopy, IEnumerable<CollectionUser> collectionUsers, string errorMessage)
|
||||
{
|
||||
var collectionUser = collectionUsers.FirstOrDefault();
|
||||
@@ -126,4 +159,161 @@ public static class BulkResourceCreationService
|
||||
|
||||
return collectionsTable;
|
||||
}
|
||||
|
||||
private static DataTable BuildCiphersTable(SqlBulkCopy bulkCopy, IEnumerable<Cipher> ciphers, string errorMessage)
|
||||
{
|
||||
var c = ciphers.FirstOrDefault();
|
||||
|
||||
if (c == null)
|
||||
{
|
||||
throw new ApplicationException(errorMessage);
|
||||
}
|
||||
|
||||
var ciphersTable = new DataTable("CipherDataTable");
|
||||
|
||||
var idColumn = new DataColumn(nameof(c.Id), c.Id.GetType());
|
||||
ciphersTable.Columns.Add(idColumn);
|
||||
var userIdColumn = new DataColumn(nameof(c.UserId), typeof(Guid));
|
||||
ciphersTable.Columns.Add(userIdColumn);
|
||||
var organizationId = new DataColumn(nameof(c.OrganizationId), typeof(Guid));
|
||||
ciphersTable.Columns.Add(organizationId);
|
||||
var typeColumn = new DataColumn(nameof(c.Type), typeof(short));
|
||||
ciphersTable.Columns.Add(typeColumn);
|
||||
var dataColumn = new DataColumn(nameof(c.Data), typeof(string));
|
||||
ciphersTable.Columns.Add(dataColumn);
|
||||
var favoritesColumn = new DataColumn(nameof(c.Favorites), typeof(string));
|
||||
ciphersTable.Columns.Add(favoritesColumn);
|
||||
var foldersColumn = new DataColumn(nameof(c.Folders), typeof(string));
|
||||
ciphersTable.Columns.Add(foldersColumn);
|
||||
var attachmentsColumn = new DataColumn(nameof(c.Attachments), typeof(string));
|
||||
ciphersTable.Columns.Add(attachmentsColumn);
|
||||
var creationDateColumn = new DataColumn(nameof(c.CreationDate), c.CreationDate.GetType());
|
||||
ciphersTable.Columns.Add(creationDateColumn);
|
||||
var revisionDateColumn = new DataColumn(nameof(c.RevisionDate), c.RevisionDate.GetType());
|
||||
ciphersTable.Columns.Add(revisionDateColumn);
|
||||
var deletedDateColumn = new DataColumn(nameof(c.DeletedDate), typeof(DateTime));
|
||||
ciphersTable.Columns.Add(deletedDateColumn);
|
||||
var repromptColumn = new DataColumn(nameof(c.Reprompt), typeof(short));
|
||||
ciphersTable.Columns.Add(repromptColumn);
|
||||
var keyColummn = new DataColumn(nameof(c.Key), typeof(string));
|
||||
ciphersTable.Columns.Add(keyColummn);
|
||||
|
||||
foreach (DataColumn col in ciphersTable.Columns)
|
||||
{
|
||||
bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName);
|
||||
}
|
||||
|
||||
var keys = new DataColumn[1];
|
||||
keys[0] = idColumn;
|
||||
ciphersTable.PrimaryKey = keys;
|
||||
|
||||
foreach (var cipher in ciphers)
|
||||
{
|
||||
var row = ciphersTable.NewRow();
|
||||
|
||||
row[idColumn] = cipher.Id;
|
||||
row[userIdColumn] = cipher.UserId.HasValue ? (object)cipher.UserId.Value : DBNull.Value;
|
||||
row[organizationId] = cipher.OrganizationId.HasValue ? (object)cipher.OrganizationId.Value : DBNull.Value;
|
||||
row[typeColumn] = (short)cipher.Type;
|
||||
row[dataColumn] = cipher.Data;
|
||||
row[favoritesColumn] = cipher.Favorites;
|
||||
row[foldersColumn] = cipher.Folders;
|
||||
row[attachmentsColumn] = cipher.Attachments;
|
||||
row[creationDateColumn] = cipher.CreationDate;
|
||||
row[revisionDateColumn] = cipher.RevisionDate;
|
||||
row[deletedDateColumn] = cipher.DeletedDate.HasValue ? (object)cipher.DeletedDate : DBNull.Value;
|
||||
row[repromptColumn] = cipher.Reprompt.HasValue ? cipher.Reprompt.Value : DBNull.Value;
|
||||
row[keyColummn] = cipher.Key;
|
||||
|
||||
ciphersTable.Rows.Add(row);
|
||||
}
|
||||
|
||||
return ciphersTable;
|
||||
}
|
||||
|
||||
private static DataTable BuildFoldersTable(SqlBulkCopy bulkCopy, IEnumerable<Folder> folders, string errorMessage)
|
||||
{
|
||||
var f = folders.FirstOrDefault();
|
||||
|
||||
if (f == null)
|
||||
{
|
||||
throw new ApplicationException(errorMessage);
|
||||
}
|
||||
|
||||
var foldersTable = new DataTable("FolderDataTable");
|
||||
|
||||
var idColumn = new DataColumn(nameof(f.Id), f.Id.GetType());
|
||||
foldersTable.Columns.Add(idColumn);
|
||||
var userIdColumn = new DataColumn(nameof(f.UserId), f.UserId.GetType());
|
||||
foldersTable.Columns.Add(userIdColumn);
|
||||
var nameColumn = new DataColumn(nameof(f.Name), typeof(string));
|
||||
foldersTable.Columns.Add(nameColumn);
|
||||
var creationDateColumn = new DataColumn(nameof(f.CreationDate), f.CreationDate.GetType());
|
||||
foldersTable.Columns.Add(creationDateColumn);
|
||||
var revisionDateColumn = new DataColumn(nameof(f.RevisionDate), f.RevisionDate.GetType());
|
||||
foldersTable.Columns.Add(revisionDateColumn);
|
||||
|
||||
foreach (DataColumn col in foldersTable.Columns)
|
||||
{
|
||||
bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName);
|
||||
}
|
||||
|
||||
var keys = new DataColumn[1];
|
||||
keys[0] = idColumn;
|
||||
foldersTable.PrimaryKey = keys;
|
||||
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
var row = foldersTable.NewRow();
|
||||
|
||||
row[idColumn] = folder.Id;
|
||||
row[userIdColumn] = folder.UserId;
|
||||
row[nameColumn] = folder.Name;
|
||||
row[creationDateColumn] = folder.CreationDate;
|
||||
row[revisionDateColumn] = folder.RevisionDate;
|
||||
|
||||
foldersTable.Rows.Add(row);
|
||||
}
|
||||
|
||||
return foldersTable;
|
||||
}
|
||||
|
||||
private static DataTable BuildCollectionCiphersTable(SqlBulkCopy bulkCopy, IEnumerable<CollectionCipher> collectionCiphers, string errorMessage)
|
||||
{
|
||||
var cc = collectionCiphers.FirstOrDefault();
|
||||
|
||||
if (cc == null)
|
||||
{
|
||||
throw new ApplicationException(errorMessage);
|
||||
}
|
||||
|
||||
var collectionCiphersTable = new DataTable("CollectionCipherDataTable");
|
||||
|
||||
var collectionIdColumn = new DataColumn(nameof(cc.CollectionId), cc.CollectionId.GetType());
|
||||
collectionCiphersTable.Columns.Add(collectionIdColumn);
|
||||
var cipherIdColumn = new DataColumn(nameof(cc.CipherId), cc.CipherId.GetType());
|
||||
collectionCiphersTable.Columns.Add(cipherIdColumn);
|
||||
|
||||
foreach (DataColumn col in collectionCiphersTable.Columns)
|
||||
{
|
||||
bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName);
|
||||
}
|
||||
|
||||
var keys = new DataColumn[2];
|
||||
keys[0] = collectionIdColumn;
|
||||
keys[1] = cipherIdColumn;
|
||||
collectionCiphersTable.PrimaryKey = keys;
|
||||
|
||||
foreach (var collectionCipher in collectionCiphers)
|
||||
{
|
||||
var row = collectionCiphersTable.NewRow();
|
||||
|
||||
row[collectionIdColumn] = collectionCipher.CollectionId;
|
||||
row[cipherIdColumn] = collectionCipher.CipherId;
|
||||
|
||||
collectionCiphersTable.Rows.Add(row);
|
||||
}
|
||||
|
||||
return collectionCiphersTable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Models.Data;
|
||||
using Bit.Core.Vault.Repositories;
|
||||
using Bit.Infrastructure.Dapper.AdminConsole.Helpers;
|
||||
using Bit.Infrastructure.Dapper.Repositories;
|
||||
using Bit.Infrastructure.Dapper.Vault.Helpers;
|
||||
using Dapper;
|
||||
@@ -408,6 +409,52 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation_vNext(
|
||||
Guid userId, IEnumerable<Cipher> ciphers)
|
||||
{
|
||||
return async (SqlConnection connection, SqlTransaction transaction) =>
|
||||
{
|
||||
// Create temp table
|
||||
var sqlCreateTemp = @"
|
||||
SELECT TOP 0 *
|
||||
INTO #TempCipher
|
||||
FROM [dbo].[Cipher]";
|
||||
|
||||
await using (var cmd = new SqlCommand(sqlCreateTemp, connection, transaction))
|
||||
{
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Bulk copy data into temp table
|
||||
await BulkResourceCreationService.CreateTempCiphersAsync(connection, transaction, ciphers);
|
||||
|
||||
// Update cipher table from temp table
|
||||
var sql = @"
|
||||
UPDATE
|
||||
[dbo].[Cipher]
|
||||
SET
|
||||
[Data] = TC.[Data],
|
||||
[Attachments] = TC.[Attachments],
|
||||
[RevisionDate] = TC.[RevisionDate],
|
||||
[Key] = TC.[Key]
|
||||
FROM
|
||||
[dbo].[Cipher] C
|
||||
INNER JOIN
|
||||
#TempCipher TC ON C.Id = TC.Id
|
||||
WHERE
|
||||
C.[UserId] = @UserId
|
||||
|
||||
DROP TABLE #TempCipher";
|
||||
|
||||
await using (var cmd = new SqlCommand(sql, connection, transaction))
|
||||
{
|
||||
cmd.Parameters.Add("@UserId", SqlDbType.UniqueIdentifier).Value = userId;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async Task UpdateCiphersAsync(Guid userId, IEnumerable<Cipher> ciphers)
|
||||
{
|
||||
if (!ciphers.Any())
|
||||
@@ -490,6 +537,83 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateCiphersAsync_vNext(Guid userId, IEnumerable<Cipher> ciphers)
|
||||
{
|
||||
if (!ciphers.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
connection.Open();
|
||||
|
||||
using (var transaction = connection.BeginTransaction())
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. Create temp tables to bulk copy into.
|
||||
|
||||
var sqlCreateTemp = @"
|
||||
SELECT TOP 0 *
|
||||
INTO #TempCipher
|
||||
FROM [dbo].[Cipher]";
|
||||
|
||||
using (var cmd = new SqlCommand(sqlCreateTemp, connection, transaction))
|
||||
{
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// 2. Bulk copy into temp tables.
|
||||
await BulkResourceCreationService.CreateTempCiphersAsync(connection, transaction, ciphers);
|
||||
|
||||
// 3. Insert into real tables from temp tables and clean up.
|
||||
|
||||
// Intentionally not including Favorites, Folders, and CreationDate
|
||||
// since those are not meant to be bulk updated at this time
|
||||
var sql = @"
|
||||
UPDATE
|
||||
[dbo].[Cipher]
|
||||
SET
|
||||
[UserId] = TC.[UserId],
|
||||
[OrganizationId] = TC.[OrganizationId],
|
||||
[Type] = TC.[Type],
|
||||
[Data] = TC.[Data],
|
||||
[Attachments] = TC.[Attachments],
|
||||
[RevisionDate] = TC.[RevisionDate],
|
||||
[DeletedDate] = TC.[DeletedDate],
|
||||
[Key] = TC.[Key]
|
||||
FROM
|
||||
[dbo].[Cipher] C
|
||||
INNER JOIN
|
||||
#TempCipher TC ON C.Id = TC.Id
|
||||
WHERE
|
||||
C.[UserId] = @UserId
|
||||
|
||||
DROP TABLE #TempCipher";
|
||||
|
||||
using (var cmd = new SqlCommand(sql, connection, transaction))
|
||||
{
|
||||
cmd.Parameters.Add("@UserId", SqlDbType.UniqueIdentifier).Value = userId;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
$"[{Schema}].[User_BumpAccountRevisionDate]",
|
||||
new { Id = userId },
|
||||
commandType: CommandType.StoredProcedure, transaction: transaction);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CreateAsync(Guid userId, IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders)
|
||||
{
|
||||
if (!ciphers.Any())
|
||||
@@ -538,6 +662,44 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CreateAsync_vNext(Guid userId, IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders)
|
||||
{
|
||||
if (!ciphers.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
connection.Open();
|
||||
|
||||
using (var transaction = connection.BeginTransaction())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (folders.Any())
|
||||
{
|
||||
await BulkResourceCreationService.CreateFoldersAsync(connection, transaction, folders);
|
||||
}
|
||||
|
||||
await BulkResourceCreationService.CreateCiphersAsync(connection, transaction, ciphers);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
$"[{Schema}].[User_BumpAccountRevisionDate]",
|
||||
new { Id = userId },
|
||||
commandType: CommandType.StoredProcedure, transaction: transaction);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CreateAsync(IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections,
|
||||
IEnumerable<CollectionCipher> collectionCiphers, IEnumerable<CollectionUser> collectionUsers)
|
||||
{
|
||||
@@ -607,6 +769,55 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CreateAsync_vNext(IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections,
|
||||
IEnumerable<CollectionCipher> collectionCiphers, IEnumerable<CollectionUser> collectionUsers)
|
||||
{
|
||||
if (!ciphers.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
connection.Open();
|
||||
|
||||
using (var transaction = connection.BeginTransaction())
|
||||
{
|
||||
try
|
||||
{
|
||||
await BulkResourceCreationService.CreateCiphersAsync(connection, transaction, ciphers);
|
||||
|
||||
if (collections.Any())
|
||||
{
|
||||
await BulkResourceCreationService.CreateCollectionsAsync(connection, transaction, collections);
|
||||
}
|
||||
|
||||
if (collectionCiphers.Any())
|
||||
{
|
||||
await BulkResourceCreationService.CreateCollectionCiphersAsync(connection, transaction, collectionCiphers);
|
||||
}
|
||||
|
||||
if (collectionUsers.Any())
|
||||
{
|
||||
await BulkResourceCreationService.CreateCollectionsUsersAsync(connection, transaction, collectionUsers);
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
$"[{Schema}].[User_BumpAccountRevisionDateByOrganizationId]",
|
||||
new { OrganizationId = ciphers.First().OrganizationId },
|
||||
commandType: CommandType.StoredProcedure, transaction: transaction);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SoftDeleteAsync(IEnumerable<Guid> ids, Guid userId)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
|
||||
@@ -152,7 +152,7 @@ public class OrganizationDomainRepository : Repository<Core.Entities.Organizatio
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
|
||||
var expiredDomains = await dbContext.OrganizationDomains
|
||||
.Where(x => x.LastCheckedDate < DateTime.UtcNow.AddDays(-expirationPeriod))
|
||||
.Where(x => x.LastCheckedDate < DateTime.UtcNow.AddDays(-expirationPeriod) && x.VerifiedDate == null)
|
||||
.ToListAsync();
|
||||
dbContext.OrganizationDomains.RemoveRange(expiredDomains);
|
||||
return await dbContext.SaveChangesAsync() > 0;
|
||||
|
||||
@@ -167,6 +167,16 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="CreateAsync(Guid, IEnumerable{Cipher}, IEnumerable{Folder})"/>
|
||||
/// <remarks>
|
||||
/// EF does not use the bulk resource creation service, so we need to use the regular create method.
|
||||
/// </remarks>
|
||||
public async Task CreateAsync_vNext(Guid userId, IEnumerable<Core.Vault.Entities.Cipher> ciphers,
|
||||
IEnumerable<Core.Vault.Entities.Folder> folders)
|
||||
{
|
||||
await CreateAsync(userId, ciphers, folders);
|
||||
}
|
||||
|
||||
public async Task CreateAsync(IEnumerable<Core.Vault.Entities.Cipher> ciphers,
|
||||
IEnumerable<Core.Entities.Collection> collections,
|
||||
IEnumerable<Core.Entities.CollectionCipher> collectionCiphers,
|
||||
@@ -205,6 +215,18 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="CreateAsync(IEnumerable{Cipher}, IEnumerable{Collection}, IEnumerable{CollectionCipher}, IEnumerable{CollectionUser})"/>
|
||||
/// <remarks>
|
||||
/// EF does not use the bulk resource creation service, so we need to use the regular create method.
|
||||
/// </remarks>
|
||||
public async Task CreateAsync_vNext(IEnumerable<Core.Vault.Entities.Cipher> ciphers,
|
||||
IEnumerable<Core.Entities.Collection> collections,
|
||||
IEnumerable<Core.Entities.CollectionCipher> collectionCiphers,
|
||||
IEnumerable<Core.Entities.CollectionUser> collectionUsers)
|
||||
{
|
||||
await CreateAsync(ciphers, collections, collectionCiphers, collectionUsers);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(IEnumerable<Guid> ids, Guid userId)
|
||||
{
|
||||
await ToggleCipherStates(ids, userId, CipherStateAction.HardDelete);
|
||||
@@ -907,6 +929,15 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="UpdateCiphersAsync(Guid, IEnumerable{Cipher})"/>
|
||||
/// <remarks>
|
||||
/// EF does not use the bulk resource creation service, so we need to use the regular update method.
|
||||
/// </remarks>
|
||||
public async Task UpdateCiphersAsync_vNext(Guid userId, IEnumerable<Core.Vault.Entities.Cipher> ciphers)
|
||||
{
|
||||
await UpdateCiphersAsync(userId, ciphers);
|
||||
}
|
||||
|
||||
public async Task UpdatePartialAsync(Guid id, Guid userId, Guid? folderId, bool favorite)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
@@ -970,6 +1001,16 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="UpdateForKeyRotation(Guid, IEnumerable{Cipher})"/>
|
||||
/// <remarks>
|
||||
/// EF does not use the bulk resource creation service, so we need to use the regular update method.
|
||||
/// </remarks>
|
||||
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation_vNext(
|
||||
Guid userId, IEnumerable<Core.Vault.Entities.Cipher> ciphers)
|
||||
{
|
||||
return UpdateForKeyRotation(userId, ciphers);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(CipherDetails cipher)
|
||||
{
|
||||
if (cipher.Id.Equals(default))
|
||||
|
||||
25
src/SharedWeb/Swagger/ActionNameOperationFilter.cs
Normal file
25
src/SharedWeb/Swagger/ActionNameOperationFilter.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.OpenApi.Any;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Bit.SharedWeb.Swagger;
|
||||
|
||||
/// <summary>
|
||||
/// Adds the action name (function name) as an extension to each operation in the Swagger document.
|
||||
/// This can be useful for the code generation process, to generate more meaningful names for operations.
|
||||
/// Note that we add both the original action name and a snake_case version, as the codegen templates
|
||||
/// cannot do case conversions.
|
||||
/// </summary>
|
||||
public class ActionNameOperationFilter : IOperationFilter
|
||||
{
|
||||
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
||||
{
|
||||
if (!context.ApiDescription.ActionDescriptor.RouteValues.TryGetValue("action", out var action)) return;
|
||||
if (string.IsNullOrEmpty(action)) return;
|
||||
|
||||
operation.Extensions.Add("x-action-name", new OpenApiString(action));
|
||||
// We can't do case changes in the codegen templates, so we also add the snake_case version of the action name
|
||||
operation.Extensions.Add("x-action-name-snake-case", new OpenApiString(JsonNamingPolicy.SnakeCaseLower.ConvertName(action)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Bit.SharedWeb.Swagger;
|
||||
|
||||
/// <summary>
|
||||
/// Checks for duplicate operation IDs in the Swagger document, and throws an error if any are found.
|
||||
/// Operation IDs must be unique across the entire Swagger document according to the OpenAPI specification,
|
||||
/// but we use controller action names to generate them, which can lead to duplicates if a Controller function
|
||||
/// has multiple HTTP methods or if a Controller has overloaded functions.
|
||||
/// </summary>
|
||||
public class CheckDuplicateOperationIdsDocumentFilter(bool printDuplicates = true) : IDocumentFilter
|
||||
{
|
||||
public bool PrintDuplicates { get; } = printDuplicates;
|
||||
|
||||
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
|
||||
{
|
||||
var operationIdMap = new Dictionary<string, List<(string Path, OpenApiPathItem PathItem, OperationType Method, OpenApiOperation Operation)>>();
|
||||
|
||||
foreach (var (path, pathItem) in swaggerDoc.Paths)
|
||||
{
|
||||
foreach (var operation in pathItem.Operations)
|
||||
{
|
||||
if (!operationIdMap.TryGetValue(operation.Value.OperationId, out var list))
|
||||
{
|
||||
list = [];
|
||||
operationIdMap[operation.Value.OperationId] = list;
|
||||
}
|
||||
|
||||
list.Add((path, pathItem, operation.Key, operation.Value));
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Find duplicates
|
||||
var duplicates = operationIdMap.Where((kvp) => kvp.Value.Count > 1).ToList();
|
||||
if (duplicates.Count > 0)
|
||||
{
|
||||
if (PrintDuplicates)
|
||||
{
|
||||
Console.WriteLine($"\n######## Duplicate operationIds found in the schema ({duplicates.Count} found) ########\n");
|
||||
|
||||
Console.WriteLine("## Common causes of duplicate operation IDs:");
|
||||
Console.WriteLine("- Multiple HTTP methods (GET, POST, etc.) on the same controller function");
|
||||
Console.WriteLine(" Solution: Split the methods into separate functions, and if appropiate, mark the deprecated ones with [Obsolete]");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("- Overloaded controller functions with the same name");
|
||||
Console.WriteLine(" Solution: Rename the overloaded functions to have unique names, or combine them into a single function with optional parameters");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("## The duplicate operation IDs are:");
|
||||
|
||||
foreach (var (operationId, duplicate) in duplicates)
|
||||
{
|
||||
Console.WriteLine($"- operationId: {operationId}");
|
||||
foreach (var (path, pathItem, method, operation) in duplicate)
|
||||
{
|
||||
Console.Write($" {method.ToString().ToUpper()} {path}");
|
||||
|
||||
|
||||
if (operation.Extensions.TryGetValue("x-source-file", out var sourceFile) && operation.Extensions.TryGetValue("x-source-line", out var sourceLine))
|
||||
{
|
||||
var sourceFileString = ((Microsoft.OpenApi.Any.OpenApiString)sourceFile).Value;
|
||||
var sourceLineString = ((Microsoft.OpenApi.Any.OpenApiInteger)sourceLine).Value;
|
||||
|
||||
Console.WriteLine($" {sourceFileString}:{sourceLineString}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
Console.WriteLine("\n");
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Duplicate operation IDs found in Swagger schema");
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs
Normal file
33
src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Bit.SharedWeb.Swagger;
|
||||
|
||||
public static class SwaggerGenOptionsExt
|
||||
{
|
||||
|
||||
public static void InitializeSwaggerFilters(
|
||||
this SwaggerGenOptions config, IWebHostEnvironment environment)
|
||||
{
|
||||
config.SchemaFilter<EnumSchemaFilter>();
|
||||
config.SchemaFilter<EncryptedStringSchemaFilter>();
|
||||
|
||||
config.OperationFilter<ActionNameOperationFilter>();
|
||||
|
||||
// Set the operation ID to the name of the controller followed by the name of the function.
|
||||
// Note that the "Controller" suffix for the controllers, and the "Async" suffix for the actions
|
||||
// are removed already, so we don't need to do that ourselves.
|
||||
// TODO(Dani): This is disabled until we remove all the duplicate operation IDs.
|
||||
// config.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}");
|
||||
// config.DocumentFilter<CheckDuplicateOperationIdsDocumentFilter>();
|
||||
|
||||
// These two filters require debug symbols/git, so only add them in development mode
|
||||
if (environment.IsDevelopment())
|
||||
{
|
||||
config.DocumentFilter<GitCommitDocumentFilter>();
|
||||
config.OperationFilter<SourceFileLineOperationFilter>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ public class AuthRequestsControllerTests
|
||||
.Returns([authRequest]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.Get();
|
||||
var result = await sutProvider.Sut.GetAll();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
|
||||
@@ -73,7 +73,7 @@ public class DevicesControllerTest
|
||||
_deviceRepositoryMock.GetManyByUserIdWithDeviceAuth(userId).Returns(devicesWithPendingAuthData);
|
||||
|
||||
// Act
|
||||
var result = await _sut.Get();
|
||||
var result = await _sut.GetAll();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
@@ -94,6 +94,6 @@ public class DevicesControllerTest
|
||||
_userServiceMock.GetProperUserId(Arg.Any<System.Security.Claims.ClaimsPrincipal>()).Returns((Guid?)null);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.Get());
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.GetAll());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ public class CollectionsControllerTests
|
||||
.GetManySharedCollectionsByOrganizationIdAsync(organization.Id)
|
||||
.Returns(collections);
|
||||
|
||||
var response = await sutProvider.Sut.Get(organization.Id);
|
||||
var response = await sutProvider.Sut.GetAll(organization.Id);
|
||||
|
||||
await sutProvider.GetDependency<ICollectionRepository>().Received(1).GetManySharedCollectionsByOrganizationIdAsync(organization.Id);
|
||||
|
||||
@@ -219,7 +219,7 @@ public class CollectionsControllerTests
|
||||
.GetManyByUserIdAsync(userId)
|
||||
.Returns(collections);
|
||||
|
||||
var result = await sutProvider.Sut.Get(organization.Id);
|
||||
var result = await sutProvider.Sut.GetAll(organization.Id);
|
||||
|
||||
await sutProvider.GetDependency<ICollectionRepository>().DidNotReceive().GetManyByOrganizationIdAsync(organization.Id);
|
||||
await sutProvider.GetDependency<ICollectionRepository>().Received(1).GetManyByUserIdAsync(userId);
|
||||
|
||||
@@ -126,7 +126,7 @@ public class ImportCiphersControllerTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.PostImport(Arg.Any<string>(), model));
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.PostImportOrganization(Arg.Any<string>(), model));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("You cannot import this much data at once.", exception.Message);
|
||||
@@ -186,7 +186,7 @@ public class ImportCiphersControllerTests
|
||||
.Returns(existingCollections.Select(c => new Collection { Id = orgIdGuid }).ToList());
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PostImport(orgId, request);
|
||||
await sutProvider.Sut.PostImportOrganization(orgId, request);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IImportCiphersCommand>()
|
||||
@@ -257,7 +257,7 @@ public class ImportCiphersControllerTests
|
||||
.Returns(existingCollections.Select(c => new Collection { Id = orgIdGuid }).ToList());
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PostImport(orgId, request);
|
||||
await sutProvider.Sut.PostImportOrganization(orgId, request);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IImportCiphersCommand>()
|
||||
@@ -324,7 +324,7 @@ public class ImportCiphersControllerTests
|
||||
|
||||
// Act
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.PostImport(orgId, request));
|
||||
sutProvider.Sut.PostImportOrganization(orgId, request));
|
||||
|
||||
// Assert
|
||||
Assert.IsType<Bit.Core.Exceptions.BadRequestException>(exception);
|
||||
@@ -387,7 +387,7 @@ public class ImportCiphersControllerTests
|
||||
|
||||
// Act
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.PostImport(orgId, request));
|
||||
sutProvider.Sut.PostImportOrganization(orgId, request));
|
||||
|
||||
// Assert
|
||||
Assert.IsType<Bit.Core.Exceptions.BadRequestException>(exception);
|
||||
@@ -457,7 +457,7 @@ public class ImportCiphersControllerTests
|
||||
// Act
|
||||
// User imports into collections and creates new collections
|
||||
// User has ImportCiphers and Create ciphers permission
|
||||
await sutProvider.Sut.PostImport(orgId.ToString(), request);
|
||||
await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IImportCiphersCommand>()
|
||||
@@ -535,7 +535,7 @@ public class ImportCiphersControllerTests
|
||||
// User has ImportCiphers permission only and doesn't have Create permission
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
{
|
||||
await sutProvider.Sut.PostImport(orgId.ToString(), request);
|
||||
await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request);
|
||||
});
|
||||
|
||||
// Assert
|
||||
@@ -610,7 +610,7 @@ public class ImportCiphersControllerTests
|
||||
// Act
|
||||
// User imports/creates a new collection - existing collections not affected
|
||||
// User has create permissions and doesn't need import permissions
|
||||
await sutProvider.Sut.PostImport(orgId.ToString(), request);
|
||||
await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IImportCiphersCommand>()
|
||||
@@ -685,7 +685,7 @@ public class ImportCiphersControllerTests
|
||||
// Act
|
||||
// User import into existing collection
|
||||
// User has ImportCiphers permission only and doesn't need create permission
|
||||
await sutProvider.Sut.PostImport(orgId.ToString(), request);
|
||||
await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IImportCiphersCommand>()
|
||||
@@ -753,7 +753,7 @@ public class ImportCiphersControllerTests
|
||||
// import ciphers only and no collections
|
||||
// User has Create permissions
|
||||
// expected to be successful
|
||||
await sutProvider.Sut.PostImport(orgId.ToString(), request);
|
||||
await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IImportCiphersCommand>()
|
||||
|
||||
@@ -12,7 +12,7 @@ public class SendAccessClaimsPrincipalExtensionsTests
|
||||
{
|
||||
// Arrange
|
||||
var guid = Guid.NewGuid();
|
||||
var claims = new[] { new Claim(Claims.SendId, guid.ToString()) };
|
||||
var claims = new[] { new Claim(Claims.SendAccessClaims.SendId, guid.ToString()) };
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));
|
||||
|
||||
// Act
|
||||
@@ -30,19 +30,19 @@ public class SendAccessClaimsPrincipalExtensionsTests
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => principal.GetSendId());
|
||||
Assert.Equal("Send ID claim not found.", ex.Message);
|
||||
Assert.Equal("send_id claim not found.", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSendId_ThrowsInvalidOperationException_WhenClaimValueIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var claims = new[] { new Claim(Claims.SendId, "not-a-guid") };
|
||||
var claims = new[] { new Claim(Claims.SendAccessClaims.SendId, "not-a-guid") };
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => principal.GetSendId());
|
||||
Assert.Equal("Invalid Send ID claim value.", ex.Message);
|
||||
Assert.Equal("Invalid send_id claim value.", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
394
test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs
Normal file
394
test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs
Normal file
@@ -0,0 +1,394 @@
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Extensions;
|
||||
|
||||
public class InvoiceExtensionsTests
|
||||
{
|
||||
private static Invoice CreateInvoiceWithLines(params InvoiceLineItem[] lineItems)
|
||||
{
|
||||
return new Invoice
|
||||
{
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = lineItems?.ToList() ?? new List<InvoiceLineItem>()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#region FormatForProvider Tests
|
||||
|
||||
[Fact]
|
||||
public void FormatForProvider_NullLines_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Lines = null
|
||||
};
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatForProvider_EmptyLines_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var invoice = CreateInvoiceWithLines();
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatForProvider_NullLineItem_SkipsNullLine()
|
||||
{
|
||||
// Arrange
|
||||
var invoice = CreateInvoiceWithLines(null);
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatForProvider_LineWithNullDescription_SkipsLine()
|
||||
{
|
||||
// Arrange
|
||||
var invoice = CreateInvoiceWithLines(
|
||||
new InvoiceLineItem { Description = null, Quantity = 1, Amount = 1000 }
|
||||
);
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatForProvider_ProviderPortalTeams_FormatsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var invoice = CreateInvoiceWithLines(
|
||||
new InvoiceLineItem
|
||||
{
|
||||
Description = "Provider Portal - Teams (at $6.00 / month)",
|
||||
Quantity = 5,
|
||||
Amount = 3000
|
||||
}
|
||||
);
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("5 × Manage service provider (at $6.00 / month)", result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatForProvider_ProviderPortalEnterprise_FormatsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var invoice = CreateInvoiceWithLines(
|
||||
new InvoiceLineItem
|
||||
{
|
||||
Description = "Provider Portal - Enterprise (at $4.00 / month)",
|
||||
Quantity = 10,
|
||||
Amount = 4000
|
||||
}
|
||||
);
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("10 × Manage service provider (at $4.00 / month)", result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatForProvider_ProviderPortalWithoutPriceInfo_FormatsWithoutPrice()
|
||||
{
|
||||
// Arrange
|
||||
var invoice = CreateInvoiceWithLines(
|
||||
new InvoiceLineItem
|
||||
{
|
||||
Description = "Provider Portal - Teams",
|
||||
Quantity = 3,
|
||||
Amount = 1800
|
||||
}
|
||||
);
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("3 × Manage service provider ", result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatForProvider_BusinessUnitPortalEnterprise_FormatsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var invoice = CreateInvoiceWithLines(
|
||||
new InvoiceLineItem
|
||||
{
|
||||
Description = "Business Unit Portal - Enterprise (at $5.00 / month)",
|
||||
Quantity = 8,
|
||||
Amount = 4000
|
||||
}
|
||||
);
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("8 × Manage service provider (at $5.00 / month)", result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatForProvider_BusinessUnitPortalGeneric_FormatsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var invoice = CreateInvoiceWithLines(
|
||||
new InvoiceLineItem
|
||||
{
|
||||
Description = "Business Unit Portal (at $3.00 / month)",
|
||||
Quantity = 2,
|
||||
Amount = 600
|
||||
}
|
||||
);
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("2 × Manage service provider (at $3.00 / month)", result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatForProvider_TaxLineWithPriceInfo_FormatsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var invoice = CreateInvoiceWithLines(
|
||||
new InvoiceLineItem
|
||||
{
|
||||
Description = "Tax (at $2.00 / month)",
|
||||
Quantity = 1,
|
||||
Amount = 200
|
||||
}
|
||||
);
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("1 × Tax (at $2.00 / month)", result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatForProvider_TaxLineWithoutPriceInfo_CalculatesPrice()
|
||||
{
|
||||
// Arrange
|
||||
var invoice = CreateInvoiceWithLines(
|
||||
new InvoiceLineItem
|
||||
{
|
||||
Description = "Tax",
|
||||
Quantity = 2,
|
||||
Amount = 400 // $4.00 total, $2.00 per item
|
||||
}
|
||||
);
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("2 × Tax (at $2.00 / month)", result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatForProvider_TaxLineWithZeroQuantity_DoesNotCalculatePrice()
|
||||
{
|
||||
// Arrange
|
||||
var invoice = CreateInvoiceWithLines(
|
||||
new InvoiceLineItem
|
||||
{
|
||||
Description = "Tax",
|
||||
Quantity = 0,
|
||||
Amount = 200
|
||||
}
|
||||
);
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("0 × Tax ", result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatForProvider_OtherLineItem_ReturnsAsIs()
|
||||
{
|
||||
// Arrange
|
||||
var invoice = CreateInvoiceWithLines(
|
||||
new InvoiceLineItem
|
||||
{
|
||||
Description = "Some other service",
|
||||
Quantity = 1,
|
||||
Amount = 1000
|
||||
}
|
||||
);
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Some other service", result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatForProvider_InvoiceLevelTax_AddsToResult()
|
||||
{
|
||||
// Arrange
|
||||
var invoice = CreateInvoiceWithLines(
|
||||
new InvoiceLineItem
|
||||
{
|
||||
Description = "Provider Portal - Teams",
|
||||
Quantity = 1,
|
||||
Amount = 600
|
||||
}
|
||||
);
|
||||
invoice.Tax = 120; // $1.20 in cents
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("1 × Manage service provider ", result[0]);
|
||||
Assert.Equal("1 × Tax (at $1.20 / month)", result[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatForProvider_NoInvoiceLevelTax_DoesNotAddTax()
|
||||
{
|
||||
// Arrange
|
||||
var invoice = CreateInvoiceWithLines(
|
||||
new InvoiceLineItem
|
||||
{
|
||||
Description = "Provider Portal - Teams",
|
||||
Quantity = 1,
|
||||
Amount = 600
|
||||
}
|
||||
);
|
||||
invoice.Tax = null;
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("1 × Manage service provider ", result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatForProvider_ZeroInvoiceLevelTax_DoesNotAddTax()
|
||||
{
|
||||
// Arrange
|
||||
var invoice = CreateInvoiceWithLines(
|
||||
new InvoiceLineItem
|
||||
{
|
||||
Description = "Provider Portal - Teams",
|
||||
Quantity = 1,
|
||||
Amount = 600
|
||||
}
|
||||
);
|
||||
invoice.Tax = 0;
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("1 × Manage service provider ", result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatForProvider_ComplexScenario_HandlesAllLineTypes()
|
||||
{
|
||||
// Arrange
|
||||
var lineItems = new StripeList<InvoiceLineItem>();
|
||||
lineItems.Data = new List<InvoiceLineItem>
|
||||
{
|
||||
new InvoiceLineItem
|
||||
{
|
||||
Description = "Provider Portal - Teams (at $6.00 / month)", Quantity = 5, Amount = 3000
|
||||
},
|
||||
new InvoiceLineItem
|
||||
{
|
||||
Description = "Provider Portal - Enterprise (at $4.00 / month)", Quantity = 10, Amount = 4000
|
||||
},
|
||||
new InvoiceLineItem { Description = "Tax", Quantity = 1, Amount = 800 },
|
||||
new InvoiceLineItem { Description = "Custom Service", Quantity = 2, Amount = 2000 }
|
||||
};
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Lines = lineItems,
|
||||
Tax = 200 // Additional $2.00 tax
|
||||
};
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
var result = invoice.FormatForProvider(subscription);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, result.Count);
|
||||
Assert.Equal("5 × Manage service provider (at $6.00 / month)", result[0]);
|
||||
Assert.Equal("10 × Manage service provider (at $4.00 / month)", result[1]);
|
||||
Assert.Equal("1 × Tax (at $8.00 / month)", result[2]);
|
||||
Assert.Equal("Custom Service", result[3]);
|
||||
Assert.Equal("1 × Tax (at $2.00 / month)", result[4]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -181,7 +181,7 @@ public class PreviewTaxAmountCommandTests
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 1 &&
|
||||
options.AutomaticTax.Enabled == false
|
||||
options.AutomaticTax.Enabled == true
|
||||
))
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
@@ -273,4 +273,269 @@ public class PreviewTaxAmountCommandTests
|
||||
var badRequest = result.AsT1;
|
||||
Assert.Equal("We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support for assistance.", badRequest.Response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_USBased_PersonalUse_SetsAutomaticTaxEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.FamiliesAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true
|
||||
));
|
||||
Assert.True(result.IsT0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_USBased_BusinessUse_SetsAutomaticTaxEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true
|
||||
));
|
||||
Assert.True(result.IsT0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NonUSBased_PersonalUse_SetsAutomaticTaxEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.FamiliesAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "CA",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true
|
||||
));
|
||||
Assert.True(result.IsT0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NonUSBased_BusinessUse_SetsAutomaticTaxEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "CA",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true
|
||||
));
|
||||
Assert.True(result.IsT0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_USBased_PersonalUse_DoesNotSetTaxExempt()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.FamiliesAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.CustomerDetails.TaxExempt == null
|
||||
));
|
||||
Assert.True(result.IsT0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_USBased_BusinessUse_DoesNotSetTaxExempt()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.CustomerDetails.TaxExempt == null
|
||||
));
|
||||
Assert.True(result.IsT0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NonUSBased_PersonalUse_DoesNotSetTaxExempt()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.FamiliesAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "CA",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.CustomerDetails.TaxExempt == null
|
||||
));
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NonUSBased_BusinessUse_SetsTaxExemptReverse()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "CA",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse
|
||||
));
|
||||
Assert.True(result.IsT0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models.StaticStore.Plans;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Tax.Models;
|
||||
using Bit.Core.Billing.Tax.Services.Implementations;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Tax.Services;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class AutomaticTaxFactoryTests
|
||||
{
|
||||
[BitAutoData]
|
||||
[Theory]
|
||||
public async Task CreateAsync_ReturnsPersonalUseStrategy_WhenSubscriberIsUser(SutProvider<AutomaticTaxFactory> sut)
|
||||
{
|
||||
var parameters = new AutomaticTaxFactoryParameters(new User(), []);
|
||||
|
||||
var actual = await sut.Sut.CreateAsync(parameters);
|
||||
|
||||
Assert.IsType<PersonalUseAutomaticTaxStrategy>(actual);
|
||||
}
|
||||
|
||||
[BitAutoData]
|
||||
[Theory]
|
||||
public async Task CreateAsync_ReturnsPersonalUseStrategy_WhenSubscriberIsOrganizationWithFamiliesAnnuallyPrice(
|
||||
SutProvider<AutomaticTaxFactory> sut)
|
||||
{
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
var parameters = new AutomaticTaxFactoryParameters(new Organization(), [familiesPlan.PasswordManager.StripePlanId]);
|
||||
|
||||
sut.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
.Returns(new FamiliesPlan());
|
||||
|
||||
sut.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually2019))
|
||||
.Returns(new Families2019Plan());
|
||||
|
||||
var actual = await sut.Sut.CreateAsync(parameters);
|
||||
|
||||
Assert.IsType<PersonalUseAutomaticTaxStrategy>(actual);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task CreateAsync_ReturnsBusinessUseStrategy_WhenSubscriberIsOrganizationWithBusinessUsePrice(
|
||||
EnterpriseAnnually plan,
|
||||
SutProvider<AutomaticTaxFactory> sut)
|
||||
{
|
||||
var parameters = new AutomaticTaxFactoryParameters(new Organization(), [plan.PasswordManager.StripePlanId]);
|
||||
|
||||
sut.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
.Returns(new FamiliesPlan());
|
||||
|
||||
sut.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually2019))
|
||||
.Returns(new Families2019Plan());
|
||||
|
||||
var actual = await sut.Sut.CreateAsync(parameters);
|
||||
|
||||
Assert.IsType<BusinessUseAutomaticTaxStrategy>(actual);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task CreateAsync_ReturnsPersonalUseStrategy_WhenPlanIsMeantForPersonalUse(SutProvider<AutomaticTaxFactory> sut)
|
||||
{
|
||||
var parameters = new AutomaticTaxFactoryParameters(PlanType.FamiliesAnnually);
|
||||
sut.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == parameters.PlanType.Value))
|
||||
.Returns(new FamiliesPlan());
|
||||
|
||||
var actual = await sut.Sut.CreateAsync(parameters);
|
||||
|
||||
Assert.IsType<PersonalUseAutomaticTaxStrategy>(actual);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task CreateAsync_ReturnsBusinessUseStrategy_WhenPlanIsMeantForBusinessUse(SutProvider<AutomaticTaxFactory> sut)
|
||||
{
|
||||
var parameters = new AutomaticTaxFactoryParameters(PlanType.EnterpriseAnnually);
|
||||
sut.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == parameters.PlanType.Value))
|
||||
.Returns(new EnterprisePlan(true));
|
||||
|
||||
var actual = await sut.Sut.CreateAsync(parameters);
|
||||
|
||||
Assert.IsType<BusinessUseAutomaticTaxStrategy>(actual);
|
||||
}
|
||||
|
||||
public record EnterpriseAnnually : EnterprisePlan
|
||||
{
|
||||
public EnterpriseAnnually() : base(true)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,492 +0,0 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Tax.Services.Implementations;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Tax.Services;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class BusinessUseAutomaticTaxStrategyTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void GetUpdateOptions_ReturnsNull_WhenFeatureFlagAllowingToUpdateSubscriptionsIsDisabled(
|
||||
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription();
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(false);
|
||||
|
||||
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||
|
||||
Assert.Null(actual);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void GetUpdateOptions_ReturnsNull_WhenSubscriptionDoesNotNeedUpdating(
|
||||
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTax
|
||||
{
|
||||
Enabled = true
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Address = new Address
|
||||
{
|
||||
Country = "US",
|
||||
},
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(true);
|
||||
|
||||
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||
|
||||
Assert.Null(actual);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void GetUpdateOptions_SetsAutomaticTaxToFalse_WhenTaxLocationIsUnrecognizedOrInvalid(
|
||||
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTax
|
||||
{
|
||||
Enabled = true
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.UnrecognizedLocation
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(true);
|
||||
|
||||
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||
|
||||
Assert.NotNull(actual);
|
||||
Assert.False(actual.AutomaticTax.Enabled);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForAmericanCustomers(
|
||||
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTax
|
||||
{
|
||||
Enabled = false
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Address = new Address
|
||||
{
|
||||
Country = "US",
|
||||
},
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(true);
|
||||
|
||||
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||
|
||||
Assert.NotNull(actual);
|
||||
Assert.True(actual.AutomaticTax.Enabled);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithTaxIds(
|
||||
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTax
|
||||
{
|
||||
Enabled = false
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Address = new Address
|
||||
{
|
||||
Country = "ES",
|
||||
},
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
},
|
||||
TaxIds = new StripeList<TaxId>
|
||||
{
|
||||
Data = new List<TaxId>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Country = "ES",
|
||||
Type = "eu_vat",
|
||||
Value = "ESZ8880999Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(true);
|
||||
|
||||
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||
|
||||
Assert.NotNull(actual);
|
||||
Assert.True(actual.AutomaticTax.Enabled);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void GetUpdateOptions_ThrowsArgumentNullException_WhenTaxIdsIsNull(
|
||||
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTax
|
||||
{
|
||||
Enabled = true
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Address = new Address
|
||||
{
|
||||
Country = "ES",
|
||||
},
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
},
|
||||
TaxIds = null
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(true);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => sutProvider.Sut.GetUpdateOptions(subscription));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithoutTaxIds(
|
||||
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTax
|
||||
{
|
||||
Enabled = true
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Address = new Address
|
||||
{
|
||||
Country = "ES",
|
||||
},
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
},
|
||||
TaxIds = new StripeList<TaxId>
|
||||
{
|
||||
Data = new List<TaxId>()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(true);
|
||||
|
||||
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||
|
||||
Assert.NotNull(actual);
|
||||
Assert.False(actual.AutomaticTax.Enabled);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void SetUpdateOptions_SetsNothing_WhenFeatureFlagAllowingToUpdateSubscriptionsIsDisabled(
|
||||
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var options = new SubscriptionUpdateOptions();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Customer = new Customer
|
||||
{
|
||||
Address = new()
|
||||
{
|
||||
Country = "US"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.Sut.SetUpdateOptions(options, subscription);
|
||||
|
||||
Assert.Null(options.AutomaticTax);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void SetUpdateOptions_SetsNothing_WhenSubscriptionDoesNotNeedUpdating(
|
||||
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var options = new SubscriptionUpdateOptions();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTax
|
||||
{
|
||||
Enabled = true
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Address = new Address
|
||||
{
|
||||
Country = "US",
|
||||
},
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.Sut.SetUpdateOptions(options, subscription);
|
||||
|
||||
Assert.Null(options.AutomaticTax);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void SetUpdateOptions_SetsAutomaticTaxToFalse_WhenTaxLocationIsUnrecognizedOrInvalid(
|
||||
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var options = new SubscriptionUpdateOptions();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTax
|
||||
{
|
||||
Enabled = true
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.UnrecognizedLocation
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.Sut.SetUpdateOptions(options, subscription);
|
||||
|
||||
Assert.False(options.AutomaticTax.Enabled);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void SetUpdateOptions_SetsAutomaticTaxToTrue_ForAmericanCustomers(
|
||||
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var options = new SubscriptionUpdateOptions();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTax
|
||||
{
|
||||
Enabled = false
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Address = new Address
|
||||
{
|
||||
Country = "US",
|
||||
},
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.Sut.SetUpdateOptions(options, subscription);
|
||||
|
||||
Assert.True(options.AutomaticTax!.Enabled);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void SetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithTaxIds(
|
||||
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var options = new SubscriptionUpdateOptions();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTax
|
||||
{
|
||||
Enabled = false
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Address = new Address
|
||||
{
|
||||
Country = "ES",
|
||||
},
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
},
|
||||
TaxIds = new StripeList<TaxId>
|
||||
{
|
||||
Data = new List<TaxId>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Country = "ES",
|
||||
Type = "eu_vat",
|
||||
Value = "ESZ8880999Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.Sut.SetUpdateOptions(options, subscription);
|
||||
|
||||
Assert.True(options.AutomaticTax!.Enabled);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void SetUpdateOptions_ThrowsArgumentNullException_WhenTaxIdsIsNull(
|
||||
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var options = new SubscriptionUpdateOptions();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTax
|
||||
{
|
||||
Enabled = true
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Address = new Address
|
||||
{
|
||||
Country = "ES",
|
||||
},
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
},
|
||||
TaxIds = null
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(true);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => sutProvider.Sut.SetUpdateOptions(options, subscription));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void SetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithoutTaxIds(
|
||||
SutProvider<BusinessUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var options = new SubscriptionUpdateOptions();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTax
|
||||
{
|
||||
Enabled = true
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Address = new Address
|
||||
{
|
||||
Country = "ES",
|
||||
},
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
},
|
||||
TaxIds = new StripeList<TaxId>
|
||||
{
|
||||
Data = new List<TaxId>()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.Sut.SetUpdateOptions(options, subscription);
|
||||
|
||||
Assert.False(options.AutomaticTax!.Enabled);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using Bit.Core.Billing.Tax.Services;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Tax.Services;
|
||||
|
||||
/// <param name="isAutomaticTaxEnabled">
|
||||
/// Whether the subscription options will have automatic tax enabled or not.
|
||||
/// </param>
|
||||
public class FakeAutomaticTaxStrategy(
|
||||
bool isAutomaticTaxEnabled) : IAutomaticTaxStrategy
|
||||
{
|
||||
public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription)
|
||||
{
|
||||
return new SubscriptionUpdateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = isAutomaticTaxEnabled }
|
||||
};
|
||||
}
|
||||
|
||||
public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer)
|
||||
{
|
||||
options.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = isAutomaticTaxEnabled };
|
||||
}
|
||||
|
||||
public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription)
|
||||
{
|
||||
options.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = isAutomaticTaxEnabled };
|
||||
}
|
||||
|
||||
public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options)
|
||||
{
|
||||
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = isAutomaticTaxEnabled };
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Tax.Services.Implementations;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Tax.Services;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class PersonalUseAutomaticTaxStrategyTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void GetUpdateOptions_ReturnsNull_WhenFeatureFlagAllowingToUpdateSubscriptionsIsDisabled(
|
||||
SutProvider<PersonalUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription();
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(false);
|
||||
|
||||
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||
|
||||
Assert.Null(actual);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void GetUpdateOptions_ReturnsNull_WhenSubscriptionDoesNotNeedUpdating(
|
||||
SutProvider<PersonalUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTax
|
||||
{
|
||||
Enabled = true
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Address = new Address
|
||||
{
|
||||
Country = "US",
|
||||
},
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(true);
|
||||
|
||||
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||
|
||||
Assert.Null(actual);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void GetUpdateOptions_SetsAutomaticTaxToFalse_WhenTaxLocationIsUnrecognizedOrInvalid(
|
||||
SutProvider<PersonalUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTax
|
||||
{
|
||||
Enabled = true
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.UnrecognizedLocation
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(true);
|
||||
|
||||
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||
|
||||
Assert.NotNull(actual);
|
||||
Assert.False(actual.AutomaticTax.Enabled);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("CA")]
|
||||
[BitAutoData("ES")]
|
||||
[BitAutoData("US")]
|
||||
public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForAllCountries(
|
||||
string country, SutProvider<PersonalUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTax
|
||||
{
|
||||
Enabled = false
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Address = new Address
|
||||
{
|
||||
Country = country
|
||||
},
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(true);
|
||||
|
||||
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||
|
||||
Assert.NotNull(actual);
|
||||
Assert.True(actual.AutomaticTax.Enabled);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("CA")]
|
||||
[BitAutoData("ES")]
|
||||
[BitAutoData("US")]
|
||||
public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithTaxIds(
|
||||
string country, SutProvider<PersonalUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTax
|
||||
{
|
||||
Enabled = false
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Address = new Address
|
||||
{
|
||||
Country = country,
|
||||
},
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
},
|
||||
TaxIds = new StripeList<TaxId>
|
||||
{
|
||||
Data = new List<TaxId>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Country = "ES",
|
||||
Type = "eu_vat",
|
||||
Value = "ESZ8880999Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(true);
|
||||
|
||||
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||
|
||||
Assert.NotNull(actual);
|
||||
Assert.True(actual.AutomaticTax.Enabled);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("CA")]
|
||||
[BitAutoData("ES")]
|
||||
[BitAutoData("US")]
|
||||
public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithoutTaxIds(
|
||||
string country, SutProvider<PersonalUseAutomaticTaxStrategy> sutProvider)
|
||||
{
|
||||
var subscription = new Subscription
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTax
|
||||
{
|
||||
Enabled = false
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Address = new Address
|
||||
{
|
||||
Country = country
|
||||
},
|
||||
Tax = new CustomerTax
|
||||
{
|
||||
AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported
|
||||
},
|
||||
TaxIds = new StripeList<TaxId>
|
||||
{
|
||||
Data = new List<TaxId>()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(Arg.Is<string>(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates))
|
||||
.Returns(true);
|
||||
|
||||
var actual = sutProvider.Sut.GetUpdateOptions(subscription);
|
||||
|
||||
Assert.NotNull(actual);
|
||||
Assert.True(actual.AutomaticTax.Enabled);
|
||||
}
|
||||
}
|
||||
@@ -247,11 +247,18 @@ public class HandlebarsMailServiceTests
|
||||
}
|
||||
}
|
||||
|
||||
// Remove this test when we add actual tests. It only proves that
|
||||
// we've properly constructed the system under test.
|
||||
[Fact]
|
||||
public void ServiceExists()
|
||||
public async Task SendSendEmailOtpEmailAsync_SendsEmail()
|
||||
{
|
||||
Assert.NotNull(_sut);
|
||||
// Arrange
|
||||
var email = "test@example.com";
|
||||
var token = "aToken";
|
||||
var subject = string.Format("Your Bitwarden Send verification code is {0}", token);
|
||||
|
||||
// Act
|
||||
await _sut.SendSendEmailOtpEmailAsync(email, token, subject);
|
||||
|
||||
// Assert
|
||||
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Any<MailMessage>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models.StaticStore.Plans;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Tax.Models;
|
||||
using Bit.Core.Billing.Tax.Requests;
|
||||
using Bit.Core.Billing.Tax.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Test.Billing.Tax.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
@@ -23,10 +21,6 @@ public class StripePaymentServiceTests
|
||||
public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithoutAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IAutomaticTaxFactory>()
|
||||
.CreateAsync(Arg.Is<AutomaticTaxFactoryParameters>(p => p.PlanType == PlanType.FamiliesAnnually))
|
||||
.Returns(new FakeAutomaticTaxStrategy(true));
|
||||
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
@@ -74,10 +68,6 @@ public class StripePaymentServiceTests
|
||||
public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IAutomaticTaxFactory>()
|
||||
.CreateAsync(Arg.Is<AutomaticTaxFactoryParameters>(p => p.PlanType == PlanType.FamiliesAnnually))
|
||||
.Returns(new FakeAutomaticTaxStrategy(true));
|
||||
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
@@ -125,10 +115,6 @@ public class StripePaymentServiceTests
|
||||
public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithoutAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IAutomaticTaxFactory>()
|
||||
.CreateAsync(Arg.Is<AutomaticTaxFactoryParameters>(p => p.PlanType == PlanType.FamiliesAnnually))
|
||||
.Returns(new FakeAutomaticTaxStrategy(true));
|
||||
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
@@ -177,10 +163,6 @@ public class StripePaymentServiceTests
|
||||
public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IAutomaticTaxFactory>()
|
||||
.CreateAsync(Arg.Is<AutomaticTaxFactoryParameters>(p => p.PlanType == PlanType.FamiliesAnnually))
|
||||
.Returns(new FakeAutomaticTaxStrategy(true));
|
||||
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
@@ -223,4 +205,340 @@ public class StripePaymentServiceTests
|
||||
Assert.Equal(4.08M, actual.TotalAmount);
|
||||
Assert.Equal(4M, actual.TaxableBaseAmount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_USBased_PersonalUse_SetsAutomaticTaxEnabled(SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
.Returns(familiesPlan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter
|
||||
.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
Total = 408
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true
|
||||
));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_USBased_BusinessUse_SetsAutomaticTaxEnabled(SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var plan = new EnterprisePlan(true);
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.EnterpriseAnnually))
|
||||
.Returns(plan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.EnterpriseAnnually
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter
|
||||
.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
Total = 408
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true
|
||||
));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_NonUSBased_PersonalUse_SetsAutomaticTaxEnabled(SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
.Returns(familiesPlan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "FR",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter
|
||||
.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
Total = 408
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true
|
||||
));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_NonUSBased_BusinessUse_SetsAutomaticTaxEnabled(SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var plan = new EnterprisePlan(true);
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.EnterpriseAnnually))
|
||||
.Returns(plan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.EnterpriseAnnually
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "FR",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter
|
||||
.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
Total = 408
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true
|
||||
));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_USBased_PersonalUse_DoesNotSetTaxExempt(SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
.Returns(familiesPlan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter
|
||||
.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
Total = 408
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.CustomerDetails.TaxExempt == null
|
||||
));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_USBased_BusinessUse_DoesNotSetTaxExempt(SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var plan = new EnterprisePlan(true);
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.EnterpriseAnnually))
|
||||
.Returns(plan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.EnterpriseAnnually
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter
|
||||
.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
Total = 408
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.CustomerDetails.TaxExempt == null
|
||||
));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_NonUSBased_PersonalUse_DoesNotSetTaxExempt(SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.FamiliesAnnually))
|
||||
.Returns(familiesPlan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "FR",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter
|
||||
.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
Total = 408
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.CustomerDetails.TaxExempt == null
|
||||
));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_NonUSBased_BusinessUse_SetsTaxExemptReverse(SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var plan = new EnterprisePlan(true);
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(Arg.Is<PlanType>(p => p == PlanType.EnterpriseAnnually))
|
||||
.Returns(plan);
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.EnterpriseAnnually
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "FR",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter
|
||||
.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
Total = 408
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,41 @@ public class ImportCiphersAsyncCommandTests
|
||||
await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ICipherRepository>().Received(1).CreateAsync(importingUserId, ciphers, Arg.Any<List<Folder>>());
|
||||
await sutProvider.GetDependency<ICipherRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync(importingUserId, ciphers, Arg.Any<List<Folder>>());
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportIntoIndividualVaultAsync_WithBulkResourceCreationServiceEnabled_Success(
|
||||
Guid importingUserId,
|
||||
List<CipherDetails> ciphers,
|
||||
SutProvider<ImportCiphersCommand> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IPolicyService>()
|
||||
.AnyPoliciesApplicableToUserAsync(importingUserId, PolicyType.OrganizationDataOwnership)
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IFolderRepository>()
|
||||
.GetManyByUserIdAsync(importingUserId)
|
||||
.Returns(new List<Folder>());
|
||||
|
||||
var folders = new List<Folder> { new Folder { UserId = importingUserId } };
|
||||
|
||||
var folderRelationships = new List<KeyValuePair<int, int>>();
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ICipherRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync_vNext(importingUserId, ciphers, Arg.Any<List<Folder>>());
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
|
||||
}
|
||||
|
||||
@@ -77,7 +111,45 @@ public class ImportCiphersAsyncCommandTests
|
||||
|
||||
await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId);
|
||||
|
||||
await sutProvider.GetDependency<ICipherRepository>().Received(1).CreateAsync(importingUserId, ciphers, Arg.Any<List<Folder>>());
|
||||
await sutProvider.GetDependency<ICipherRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync(importingUserId, ciphers, Arg.Any<List<Folder>>());
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportIntoIndividualVaultAsync_WithBulkResourceCreationServiceEnabled_WithPolicyRequirementsEnabled_WithOrganizationDataOwnershipPolicyDisabled_Success(
|
||||
Guid importingUserId,
|
||||
List<CipherDetails> ciphers,
|
||||
SutProvider<ImportCiphersCommand> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PolicyRequirements)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||
.GetAsync<OrganizationDataOwnershipPolicyRequirement>(importingUserId)
|
||||
.Returns(new OrganizationDataOwnershipPolicyRequirement(
|
||||
OrganizationDataOwnershipState.Disabled,
|
||||
[]));
|
||||
|
||||
sutProvider.GetDependency<IFolderRepository>()
|
||||
.GetManyByUserIdAsync(importingUserId)
|
||||
.Returns(new List<Folder>());
|
||||
|
||||
var folders = new List<Folder> { new Folder { UserId = importingUserId } };
|
||||
|
||||
var folderRelationships = new List<KeyValuePair<int, int>>();
|
||||
|
||||
await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId);
|
||||
|
||||
await sutProvider.GetDependency<ICipherRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync_vNext(importingUserId, ciphers, Arg.Any<List<Folder>>());
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
|
||||
}
|
||||
|
||||
@@ -187,6 +259,66 @@ public class ImportCiphersAsyncCommandTests
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportIntoOrganizationalVaultAsync_WithBulkResourceCreationServiceEnabled_Success(
|
||||
Organization organization,
|
||||
Guid importingUserId,
|
||||
OrganizationUser importingOrganizationUser,
|
||||
List<Collection> collections,
|
||||
List<CipherDetails> ciphers,
|
||||
SutProvider<ImportCiphersCommand> sutProvider)
|
||||
{
|
||||
organization.MaxCollections = null;
|
||||
importingOrganizationUser.OrganizationId = organization.Id;
|
||||
|
||||
foreach (var collection in collections)
|
||||
{
|
||||
collection.OrganizationId = organization.Id;
|
||||
}
|
||||
|
||||
foreach (var cipher in ciphers)
|
||||
{
|
||||
cipher.OrganizationId = organization.Id;
|
||||
}
|
||||
|
||||
KeyValuePair<int, int>[] collectionRelationships = {
|
||||
new(0, 0),
|
||||
new(1, 1),
|
||||
new(2, 2)
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(organization.Id, importingUserId)
|
||||
.Returns(importingOrganizationUser);
|
||||
|
||||
// Set up a collection that already exists in the organization
|
||||
sutProvider.GetDependency<ICollectionRepository>()
|
||||
.GetManyByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new List<Collection> { collections[0] });
|
||||
|
||||
await sutProvider.Sut.ImportIntoOrganizationalVaultAsync(collections, ciphers, collectionRelationships, importingUserId);
|
||||
|
||||
await sutProvider.GetDependency<ICipherRepository>().Received(1).CreateAsync_vNext(
|
||||
ciphers,
|
||||
Arg.Is<IEnumerable<Collection>>(cols => cols.Count() == collections.Count - 1 &&
|
||||
!cols.Any(c => c.Id == collections[0].Id) && // Check that the collection that already existed in the organization was not added
|
||||
cols.All(c => collections.Any(x => c.Name == x.Name))),
|
||||
Arg.Is<IEnumerable<CollectionCipher>>(c => c.Count() == ciphers.Count),
|
||||
Arg.Is<IEnumerable<CollectionUser>>(cus =>
|
||||
cus.Count() == collections.Count - 1 &&
|
||||
!cus.Any(cu => cu.CollectionId == collections[0].Id) && // Check that access was not added for the collection that already existed in the organization
|
||||
cus.All(cu => cu.OrganizationUserId == importingOrganizationUser.Id && cu.Manage == true)));
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportIntoOrganizationalVaultAsync_ThrowsBadRequestException(
|
||||
Organization organization,
|
||||
|
||||
@@ -674,6 +674,32 @@ public class CipherServiceTests
|
||||
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("")]
|
||||
[BitAutoData("Correct Time")]
|
||||
public async Task ShareManyAsync_CorrectRevisionDate_WithBulkResourceCreationServiceEnabled_Passes(string revisionDateString,
|
||||
SutProvider<CipherService> sutProvider, IEnumerable<CipherDetails> ciphers, Organization organization, List<Guid> collectionIds)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id)
|
||||
.Returns(new Organization
|
||||
{
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
MaxStorageGb = 100
|
||||
});
|
||||
|
||||
var cipherInfos = ciphers.Select(c => (c,
|
||||
string.IsNullOrEmpty(revisionDateString) ? null : (DateTime?)c.RevisionDate));
|
||||
var sharingUserId = ciphers.First().UserId.Value;
|
||||
|
||||
await sutProvider.Sut.ShareManyAsync(cipherInfos, organization.Id, collectionIds, sharingUserId);
|
||||
await sutProvider.GetDependency<ICipherRepository>().Received(1).UpdateCiphersAsync_vNext(sharingUserId,
|
||||
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RestoreAsync_UpdatesUserCipher(Guid restoringUserId, CipherDetails cipher, SutProvider<CipherService> sutProvider)
|
||||
@@ -1094,6 +1120,33 @@ public class CipherServiceTests
|
||||
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ShareManyAsync_PaidOrgWithAttachment_WithBulkResourceCreationServiceEnabled_Passes(SutProvider<CipherService> sutProvider,
|
||||
IEnumerable<CipherDetails> ciphers, Guid organizationId, List<Guid> collectionIds)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||
.Returns(new Organization
|
||||
{
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
MaxStorageGb = 100
|
||||
});
|
||||
ciphers.FirstOrDefault().Attachments =
|
||||
"{\"attachment1\":{\"Size\":\"250\",\"FileName\":\"superCoolFile\","
|
||||
+ "\"Key\":\"superCoolFile\",\"ContainerName\":\"testContainer\",\"Validated\":false}}";
|
||||
|
||||
var cipherInfos = ciphers.Select(c => (c,
|
||||
(DateTime?)c.RevisionDate));
|
||||
var sharingUserId = ciphers.First().UserId.Value;
|
||||
|
||||
await sutProvider.Sut.ShareManyAsync(cipherInfos, organizationId, collectionIds, sharingUserId);
|
||||
await sutProvider.GetDependency<ICipherRepository>().Received(1).UpdateCiphersAsync_vNext(sharingUserId,
|
||||
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
|
||||
}
|
||||
|
||||
private class SaveDetailsAsyncDependencies
|
||||
{
|
||||
public CipherDetails CipherDetails { get; set; }
|
||||
|
||||
@@ -213,8 +213,8 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory
|
||||
services.AddSingleton(sendAuthQuery);
|
||||
|
||||
// Mock password validator to return success
|
||||
var passwordValidator = Substitute.For<ISendPasswordRequestValidator>();
|
||||
passwordValidator.ValidateSendPassword(
|
||||
var passwordValidator = Substitute.For<ISendAuthenticationMethodValidator<ResourcePassword>>();
|
||||
passwordValidator.ValidateRequestAsync(
|
||||
Arg.Any<ExtensionGrantValidationContext>(),
|
||||
Arg.Any<ResourcePassword>(),
|
||||
Arg.Any<Guid>())
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
using Bit.Core.Auth.Identity.TokenProviders;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.IdentityServer;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Identity.IdentityServer.Enums;
|
||||
using Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||
using Bit.IntegrationTestCommon.Factories;
|
||||
using Duende.IdentityModel;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Identity.IntegrationTest.RequestValidation;
|
||||
|
||||
public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture<IdentityApplicationFactory>
|
||||
{
|
||||
private readonly IdentityApplicationFactory _factory;
|
||||
|
||||
public SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAccess_EmailOtpProtectedSend_MissingEmail_ReturnsInvalidRequest()
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var client = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(Arg.Any<string>()).Returns(true);
|
||||
services.AddSingleton(featureService);
|
||||
|
||||
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
|
||||
sendAuthQuery.GetAuthenticationMethod(sendId)
|
||||
.Returns(new EmailOtp(["test@example.com"]));
|
||||
services.AddSingleton(sendAuthQuery);
|
||||
});
|
||||
}).CreateClient();
|
||||
|
||||
var requestBody = CreateTokenRequestBody(sendId); // No email
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync("/connect/token", requestBody);
|
||||
|
||||
// Assert
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content);
|
||||
Assert.Contains("email is required", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAccess_EmailOtpProtectedSend_EmailWithoutOtp_SendsOtpEmail()
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var email = "test@example.com";
|
||||
var generatedToken = "123456";
|
||||
|
||||
var client = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(Arg.Any<string>()).Returns(true);
|
||||
services.AddSingleton(featureService);
|
||||
|
||||
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
|
||||
sendAuthQuery.GetAuthenticationMethod(sendId)
|
||||
.Returns(new EmailOtp([email]));
|
||||
services.AddSingleton(sendAuthQuery);
|
||||
|
||||
// Mock OTP token provider
|
||||
var otpProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
|
||||
otpProvider.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns(generatedToken);
|
||||
services.AddSingleton(otpProvider);
|
||||
|
||||
// Mock mail service
|
||||
var mailService = Substitute.For<IMailService>();
|
||||
services.AddSingleton(mailService);
|
||||
});
|
||||
}).CreateClient();
|
||||
|
||||
var requestBody = CreateTokenRequestBody(sendId, sendEmail: email); // Email but no OTP
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync("/connect/token", requestBody);
|
||||
|
||||
// Assert
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content);
|
||||
Assert.Contains("email otp sent", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAccess_EmailOtpProtectedSend_ValidOtp_ReturnsAccessToken()
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var email = "test@example.com";
|
||||
var otp = "123456";
|
||||
|
||||
var client = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(Arg.Any<string>()).Returns(true);
|
||||
services.AddSingleton(featureService);
|
||||
|
||||
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
|
||||
sendAuthQuery.GetAuthenticationMethod(sendId)
|
||||
.Returns(new EmailOtp(new[] { email }));
|
||||
services.AddSingleton(sendAuthQuery);
|
||||
|
||||
// Mock OTP token provider to validate successfully
|
||||
var otpProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
|
||||
otpProvider.ValidateTokenAsync(otp, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns(true);
|
||||
services.AddSingleton(otpProvider);
|
||||
|
||||
var mailService = Substitute.For<IMailService>();
|
||||
services.AddSingleton(mailService);
|
||||
});
|
||||
}).CreateClient();
|
||||
|
||||
var requestBody = CreateTokenRequestBody(sendId, sendEmail: email, emailOtp: otp);
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync("/connect/token", requestBody);
|
||||
|
||||
// Assert
|
||||
Assert.True(response.IsSuccessStatusCode);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains(OidcConstants.TokenResponse.AccessToken, content);
|
||||
Assert.Contains(OidcConstants.TokenResponse.BearerTokenType, content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAccess_EmailOtpProtectedSend_InvalidOtp_ReturnsInvalidGrant()
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var email = "test@example.com";
|
||||
var invalidOtp = "wrong123";
|
||||
|
||||
var client = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(Arg.Any<string>()).Returns(true);
|
||||
services.AddSingleton(featureService);
|
||||
|
||||
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
|
||||
sendAuthQuery.GetAuthenticationMethod(sendId)
|
||||
.Returns(new EmailOtp(new[] { email }));
|
||||
services.AddSingleton(sendAuthQuery);
|
||||
|
||||
// Mock OTP token provider to validate as false
|
||||
var otpProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
|
||||
otpProvider.ValidateTokenAsync(invalidOtp, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns(false);
|
||||
services.AddSingleton(otpProvider);
|
||||
|
||||
var mailService = Substitute.For<IMailService>();
|
||||
services.AddSingleton(mailService);
|
||||
});
|
||||
}).CreateClient();
|
||||
|
||||
var requestBody = CreateTokenRequestBody(sendId, sendEmail: email, emailOtp: invalidOtp);
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync("/connect/token", requestBody);
|
||||
|
||||
// Assert
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content);
|
||||
Assert.Contains("email otp is invalid", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAccess_EmailOtpProtectedSend_OtpGenerationFails_ReturnsInvalidRequest()
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var email = "test@example.com";
|
||||
|
||||
var client = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(Arg.Any<string>()).Returns(true);
|
||||
services.AddSingleton(featureService);
|
||||
|
||||
var sendAuthQuery = Substitute.For<ISendAuthenticationQuery>();
|
||||
sendAuthQuery.GetAuthenticationMethod(sendId)
|
||||
.Returns(new EmailOtp(new[] { email }));
|
||||
services.AddSingleton(sendAuthQuery);
|
||||
|
||||
// Mock OTP token provider to fail generation
|
||||
var otpProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
|
||||
otpProvider.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns((string)null);
|
||||
services.AddSingleton(otpProvider);
|
||||
|
||||
var mailService = Substitute.For<IMailService>();
|
||||
services.AddSingleton(mailService);
|
||||
});
|
||||
}).CreateClient();
|
||||
|
||||
var requestBody = CreateTokenRequestBody(sendId, sendEmail: email); // Email but no OTP
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync("/connect/token", requestBody);
|
||||
|
||||
// Assert
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content);
|
||||
}
|
||||
|
||||
private static FormUrlEncodedContent CreateTokenRequestBody(Guid sendId,
|
||||
string sendEmail = null, string emailOtp = null)
|
||||
{
|
||||
var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray());
|
||||
var parameters = new List<KeyValuePair<string, string>>
|
||||
{
|
||||
new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess),
|
||||
new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send ),
|
||||
new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess),
|
||||
new("deviceType", ((int)DeviceType.FirefoxBrowser).ToString()),
|
||||
new(SendAccessConstants.TokenRequest.SendId, sendIdBase64)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(sendEmail))
|
||||
{
|
||||
parameters.Add(new KeyValuePair<string, string>(
|
||||
SendAccessConstants.TokenRequest.Email, sendEmail));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(emailOtp))
|
||||
{
|
||||
parameters.Add(new KeyValuePair<string, string>(
|
||||
SendAccessConstants.TokenRequest.Otp, emailOtp));
|
||||
}
|
||||
|
||||
return new FormUrlEncodedContent(parameters);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ using Duende.IdentityServer.Validation;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Identity.Test.IdentityServer;
|
||||
namespace Bit.Identity.Test.IdentityServer.SendAccess;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class SendAccessGrantValidatorTests
|
||||
@@ -167,7 +167,7 @@ public class SendAccessGrantValidatorTests
|
||||
// get the claims from the subject
|
||||
var claims = subject.Claims.ToList();
|
||||
Assert.NotEmpty(claims);
|
||||
Assert.Contains(claims, c => c.Type == Claims.SendId && c.Value == sendId.ToString());
|
||||
Assert.Contains(claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString());
|
||||
Assert.Contains(claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString());
|
||||
}
|
||||
|
||||
@@ -189,8 +189,8 @@ public class SendAccessGrantValidatorTests
|
||||
.GetAuthenticationMethod(sendId)
|
||||
.Returns(resourcePassword);
|
||||
|
||||
sutProvider.GetDependency<ISendPasswordRequestValidator>()
|
||||
.ValidateSendPassword(context, resourcePassword, sendId)
|
||||
sutProvider.GetDependency<ISendAuthenticationMethodValidator<ResourcePassword>>()
|
||||
.ValidateRequestAsync(context, resourcePassword, sendId)
|
||||
.Returns(expectedResult);
|
||||
|
||||
// Act
|
||||
@@ -198,15 +198,16 @@ public class SendAccessGrantValidatorTests
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedResult, context.Result);
|
||||
sutProvider.GetDependency<ISendPasswordRequestValidator>()
|
||||
await sutProvider.GetDependency<ISendAuthenticationMethodValidator<ResourcePassword>>()
|
||||
.Received(1)
|
||||
.ValidateSendPassword(context, resourcePassword, sendId);
|
||||
.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EmailOtpMethod_NotImplemented_ThrowsError(
|
||||
public async Task ValidateAsync_EmailOtpMethod_CallsEmailOtp(
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
SutProvider<SendAccessGrantValidator> sutProvider,
|
||||
GrantValidationResult expectedResult,
|
||||
Guid sendId,
|
||||
EmailOtp emailOtp)
|
||||
{
|
||||
@@ -216,15 +217,22 @@ public class SendAccessGrantValidatorTests
|
||||
sendId,
|
||||
tokenRequest);
|
||||
|
||||
|
||||
sutProvider.GetDependency<ISendAuthenticationQuery>()
|
||||
.GetAuthenticationMethod(sendId)
|
||||
.Returns(emailOtp);
|
||||
|
||||
sutProvider.GetDependency<ISendAuthenticationMethodValidator<EmailOtp>>()
|
||||
.ValidateRequestAsync(context, emailOtp, sendId)
|
||||
.Returns(expectedResult);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
// Currently the EmailOtp case doesn't set a result, so it should be null
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.ValidateAsync(context));
|
||||
Assert.Equal(expectedResult, context.Result);
|
||||
await sutProvider.GetDependency<ISendAuthenticationMethodValidator<EmailOtp>>()
|
||||
.Received(1)
|
||||
.ValidateRequestAsync(context, emailOtp, sendId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -256,7 +264,7 @@ public class SendAccessGrantValidatorTests
|
||||
public void GrantType_ReturnsCorrectType()
|
||||
{
|
||||
// Arrange & Act
|
||||
var validator = new SendAccessGrantValidator(null!, null!, null!);
|
||||
var validator = new SendAccessGrantValidator(null!, null!, null!, null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(CustomGrantTypes.SendAccess, ((IExtensionGrantValidator)validator).GrantType);
|
||||
@@ -0,0 +1,310 @@
|
||||
using System.Collections.Specialized;
|
||||
using Bit.Core.Auth.Identity.TokenProviders;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Identity;
|
||||
using Bit.Core.IdentityServer;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Identity.IdentityServer.Enums;
|
||||
using Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Duende.IdentityModel;
|
||||
using Duende.IdentityServer.Validation;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Identity.Test.IdentityServer.SendAccess;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class SendEmailOtpRequestValidatorTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateRequestAsync_MissingEmail_ReturnsInvalidRequest(
|
||||
SutProvider<SendEmailOtpRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
EmailOtp emailOtp,
|
||||
Guid sendId)
|
||||
{
|
||||
// Arrange
|
||||
tokenRequest.Raw = CreateValidatedTokenRequest(sendId);
|
||||
var context = new ExtensionGrantValidationContext
|
||||
{
|
||||
Request = tokenRequest
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error);
|
||||
Assert.Equal("email is required.", result.ErrorDescription);
|
||||
|
||||
// Verify no OTP generation or email sending occurred
|
||||
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||
.DidNotReceive()
|
||||
.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.DidNotReceive()
|
||||
.SendSendEmailOtpEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateRequestAsync_EmailNotInList_ReturnsInvalidRequest(
|
||||
SutProvider<SendEmailOtpRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
EmailOtp emailOtp,
|
||||
string email,
|
||||
Guid sendId)
|
||||
{
|
||||
// Arrange
|
||||
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email);
|
||||
var emailOTP = new EmailOtp(["user@test.dev"]);
|
||||
var context = new ExtensionGrantValidationContext
|
||||
{
|
||||
Request = tokenRequest
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error);
|
||||
Assert.Equal("email is invalid.", result.ErrorDescription);
|
||||
|
||||
// Verify no OTP generation or email sending occurred
|
||||
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||
.DidNotReceive()
|
||||
.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.DidNotReceive()
|
||||
.SendSendEmailOtpEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateRequestAsync_EmailWithoutOtp_GeneratesAndSendsOtp(
|
||||
SutProvider<SendEmailOtpRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
EmailOtp emailOtp,
|
||||
Guid sendId,
|
||||
string email,
|
||||
string generatedToken)
|
||||
{
|
||||
// Arrange
|
||||
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email);
|
||||
var context = new ExtensionGrantValidationContext
|
||||
{
|
||||
Request = tokenRequest
|
||||
};
|
||||
|
||||
var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email);
|
||||
|
||||
sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||
.GenerateTokenAsync(
|
||||
SendAccessConstants.OtpToken.TokenProviderName,
|
||||
SendAccessConstants.OtpToken.Purpose,
|
||||
expectedUniqueId)
|
||||
.Returns(generatedToken);
|
||||
|
||||
emailOtp = emailOtp with { Emails = [email] };
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error);
|
||||
Assert.Equal("email otp sent.", result.ErrorDescription);
|
||||
|
||||
// Verify OTP generation
|
||||
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||
.Received(1)
|
||||
.GenerateTokenAsync(
|
||||
SendAccessConstants.OtpToken.TokenProviderName,
|
||||
SendAccessConstants.OtpToken.Purpose,
|
||||
expectedUniqueId);
|
||||
|
||||
// Verify email sending
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
.SendSendEmailOtpEmailAsync(email, generatedToken, Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateRequestAsync_OtpGenerationFails_ReturnsGenerationFailedError(
|
||||
SutProvider<SendEmailOtpRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
EmailOtp emailOtp,
|
||||
Guid sendId,
|
||||
string email)
|
||||
{
|
||||
// Arrange
|
||||
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email);
|
||||
var context = new ExtensionGrantValidationContext
|
||||
{
|
||||
Request = tokenRequest
|
||||
};
|
||||
|
||||
emailOtp = emailOtp with { Emails = [email] };
|
||||
|
||||
sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||
.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns((string)null); // Generation fails
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error);
|
||||
|
||||
// Verify no email was sent
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.DidNotReceive()
|
||||
.SendSendEmailOtpEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateRequestAsync_ValidOtp_ReturnsSuccess(
|
||||
SutProvider<SendEmailOtpRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
EmailOtp emailOtp,
|
||||
Guid sendId,
|
||||
string email,
|
||||
string otp)
|
||||
{
|
||||
// Arrange
|
||||
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, otp);
|
||||
var context = new ExtensionGrantValidationContext
|
||||
{
|
||||
Request = tokenRequest
|
||||
};
|
||||
|
||||
emailOtp = emailOtp with { Emails = [email] };
|
||||
|
||||
var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email);
|
||||
|
||||
sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||
.ValidateTokenAsync(
|
||||
otp,
|
||||
SendAccessConstants.OtpToken.TokenProviderName,
|
||||
SendAccessConstants.OtpToken.Purpose,
|
||||
expectedUniqueId)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsError);
|
||||
var sub = result.Subject;
|
||||
Assert.Equal(sendId.ToString(), sub.Claims.First(c => c.Type == Claims.SendAccessClaims.SendId).Value);
|
||||
|
||||
// Verify claims
|
||||
Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString());
|
||||
Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.Email && c.Value == email);
|
||||
Assert.Contains(sub.Claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString());
|
||||
|
||||
// Verify OTP validation was called
|
||||
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||
.Received(1)
|
||||
.ValidateTokenAsync(otp, SendAccessConstants.OtpToken.TokenProviderName, SendAccessConstants.OtpToken.Purpose, expectedUniqueId);
|
||||
|
||||
// Verify no email was sent (validation only)
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.DidNotReceive()
|
||||
.SendSendEmailOtpEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateRequestAsync_InvalidOtp_ReturnsInvalidGrant(
|
||||
SutProvider<SendEmailOtpRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
EmailOtp emailOtp,
|
||||
Guid sendId,
|
||||
string email,
|
||||
string invalidOtp)
|
||||
{
|
||||
// Arrange
|
||||
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, invalidOtp);
|
||||
var context = new ExtensionGrantValidationContext
|
||||
{
|
||||
Request = tokenRequest
|
||||
};
|
||||
|
||||
emailOtp = emailOtp with { Emails = [email] };
|
||||
|
||||
var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email);
|
||||
|
||||
sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||
.ValidateTokenAsync(invalidOtp,
|
||||
SendAccessConstants.OtpToken.TokenProviderName,
|
||||
SendAccessConstants.OtpToken.Purpose,
|
||||
expectedUniqueId)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error);
|
||||
Assert.Equal("email otp is invalid.", result.ErrorDescription);
|
||||
|
||||
// Verify OTP validation was attempted
|
||||
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
|
||||
.Received(1)
|
||||
.ValidateTokenAsync(invalidOtp,
|
||||
SendAccessConstants.OtpToken.TokenProviderName,
|
||||
SendAccessConstants.OtpToken.Purpose,
|
||||
expectedUniqueId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidParameters_CreatesInstance()
|
||||
{
|
||||
// Arrange
|
||||
var otpTokenProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
|
||||
var mailService = Substitute.For<IMailService>();
|
||||
|
||||
// Act
|
||||
var validator = new SendEmailOtpRequestValidator(otpTokenProvider, mailService);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(validator);
|
||||
}
|
||||
|
||||
private static NameValueCollection CreateValidatedTokenRequest(
|
||||
Guid sendId,
|
||||
string sendEmail = null,
|
||||
string otpCode = null)
|
||||
{
|
||||
var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray());
|
||||
|
||||
var rawRequestParameters = new NameValueCollection
|
||||
{
|
||||
{ OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess },
|
||||
{ OidcConstants.TokenRequest.ClientId, BitwardenClient.Send },
|
||||
{ OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess },
|
||||
{ "device_type", ((int)DeviceType.FirefoxBrowser).ToString() },
|
||||
{ SendAccessConstants.TokenRequest.SendId, sendIdBase64 }
|
||||
};
|
||||
|
||||
if (sendEmail != null)
|
||||
{
|
||||
rawRequestParameters.Add(SendAccessConstants.TokenRequest.Email, sendEmail);
|
||||
}
|
||||
|
||||
if (otpCode != null && sendEmail != null)
|
||||
{
|
||||
rawRequestParameters.Add(SendAccessConstants.TokenRequest.Otp, otpCode);
|
||||
}
|
||||
|
||||
return rawRequestParameters;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
using System.Collections.Specialized;
|
||||
using Bit.Core.Auth.UserFeatures.SendAccess;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Identity;
|
||||
using Bit.Core.IdentityServer;
|
||||
using Bit.Core.KeyManagement.Sends;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Identity.IdentityServer.Enums;
|
||||
using Bit.Identity.IdentityServer.RequestValidators.SendAccess;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Duende.IdentityModel;
|
||||
using Duende.IdentityServer.Validation;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Identity.Test.IdentityServer.SendAccess;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class SendPasswordRequestValidatorTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateSendPassword_MissingPasswordHash_ReturnsInvalidRequest(
|
||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
ResourcePassword resourcePassword,
|
||||
Guid sendId)
|
||||
{
|
||||
// Arrange
|
||||
tokenRequest.Raw = CreateValidatedTokenRequest(sendId);
|
||||
|
||||
var context = new ExtensionGrantValidationContext
|
||||
{
|
||||
Request = tokenRequest
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error);
|
||||
Assert.Equal($"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is required.", result.ErrorDescription);
|
||||
|
||||
// Verify password hasher was not called
|
||||
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||
.DidNotReceive()
|
||||
.PasswordHashMatches(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant(
|
||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
ResourcePassword resourcePassword,
|
||||
Guid sendId,
|
||||
string clientPasswordHash)
|
||||
{
|
||||
// Arrange
|
||||
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash);
|
||||
|
||||
var context = new ExtensionGrantValidationContext
|
||||
{
|
||||
Request = tokenRequest
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||
.PasswordHashMatches(resourcePassword.Hash, clientPasswordHash)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error);
|
||||
Assert.Equal($"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is invalid.", result.ErrorDescription);
|
||||
|
||||
// Verify password hasher was called with correct parameters
|
||||
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||
.Received(1)
|
||||
.PasswordHashMatches(resourcePassword.Hash, clientPasswordHash);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateSendPassword_PasswordHashMatches_ReturnsSuccess(
|
||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
ResourcePassword resourcePassword,
|
||||
Guid sendId,
|
||||
string clientPasswordHash)
|
||||
{
|
||||
// Arrange
|
||||
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash);
|
||||
|
||||
var context = new ExtensionGrantValidationContext
|
||||
{
|
||||
Request = tokenRequest
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||
.PasswordHashMatches(resourcePassword.Hash, clientPasswordHash)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsError);
|
||||
|
||||
var sub = result.Subject;
|
||||
Assert.Equal(sendId, sub.GetSendId());
|
||||
|
||||
// Verify claims
|
||||
Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString());
|
||||
Assert.Contains(sub.Claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString());
|
||||
|
||||
// Verify password hasher was called
|
||||
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||
.Received(1)
|
||||
.PasswordHashMatches(resourcePassword.Hash, clientPasswordHash);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher(
|
||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
ResourcePassword resourcePassword,
|
||||
Guid sendId)
|
||||
{
|
||||
// Arrange
|
||||
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, string.Empty);
|
||||
|
||||
var context = new ExtensionGrantValidationContext
|
||||
{
|
||||
Request = tokenRequest
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||
.PasswordHashMatches(resourcePassword.Hash, string.Empty)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error);
|
||||
|
||||
// Verify password hasher was called with empty string
|
||||
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||
.Received(1)
|
||||
.PasswordHashMatches(resourcePassword.Hash, string.Empty);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher(
|
||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
ResourcePassword resourcePassword,
|
||||
Guid sendId)
|
||||
{
|
||||
// Arrange
|
||||
var whitespacePassword = " ";
|
||||
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, whitespacePassword);
|
||||
|
||||
var context = new ExtensionGrantValidationContext
|
||||
{
|
||||
Request = tokenRequest
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||
.PasswordHashMatches(resourcePassword.Hash, whitespacePassword)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
|
||||
// Verify password hasher was called with whitespace string
|
||||
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||
.Received(1)
|
||||
.PasswordHashMatches(resourcePassword.Hash, whitespacePassword);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant(
|
||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
ResourcePassword resourcePassword,
|
||||
Guid sendId)
|
||||
{
|
||||
// Arrange
|
||||
var firstPassword = "first-password";
|
||||
var secondPassword = "second-password";
|
||||
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, firstPassword, secondPassword);
|
||||
|
||||
var context = new ExtensionGrantValidationContext
|
||||
{
|
||||
Request = tokenRequest
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||
.PasswordHashMatches(resourcePassword.Hash, firstPassword)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error);
|
||||
|
||||
// Verify password hasher was called with first value
|
||||
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||
.Received(1)
|
||||
.PasswordHashMatches(resourcePassword.Hash, $"{firstPassword},{secondPassword}");
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateSendPassword_SuccessResult_ContainsCorrectClaims(
|
||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
ResourcePassword resourcePassword,
|
||||
Guid sendId,
|
||||
string clientPasswordHash)
|
||||
{
|
||||
// Arrange
|
||||
tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash);
|
||||
|
||||
var context = new ExtensionGrantValidationContext
|
||||
{
|
||||
Request = tokenRequest
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISendPasswordHasher>()
|
||||
.PasswordHashMatches(Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsError);
|
||||
var sub = result.Subject;
|
||||
|
||||
var sendIdClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.SendAccessClaims.SendId);
|
||||
Assert.NotNull(sendIdClaim);
|
||||
Assert.Equal(sendId.ToString(), sendIdClaim.Value);
|
||||
|
||||
var typeClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.Type);
|
||||
Assert.NotNull(typeClaim);
|
||||
Assert.Equal(IdentityClientType.Send.ToString(), typeClaim.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidParameters_CreatesInstance()
|
||||
{
|
||||
// Arrange
|
||||
var sendPasswordHasher = Substitute.For<ISendPasswordHasher>();
|
||||
|
||||
// Act
|
||||
var validator = new SendPasswordRequestValidator(sendPasswordHasher);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(validator);
|
||||
}
|
||||
|
||||
private static NameValueCollection CreateValidatedTokenRequest(
|
||||
Guid sendId,
|
||||
params string[] passwordHash)
|
||||
{
|
||||
var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray());
|
||||
|
||||
var rawRequestParameters = new NameValueCollection
|
||||
{
|
||||
{ OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess },
|
||||
{ OidcConstants.TokenRequest.ClientId, BitwardenClient.Send },
|
||||
{ OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess },
|
||||
{ "device_type", ((int)DeviceType.FirefoxBrowser).ToString() },
|
||||
{ SendAccessConstants.TokenRequest.SendId, sendIdBase64 }
|
||||
};
|
||||
|
||||
if (passwordHash != null && passwordHash.Length > 0)
|
||||
{
|
||||
foreach (var hash in passwordHash)
|
||||
{
|
||||
rawRequestParameters.Add(SendAccessConstants.TokenRequest.ClientB64HashedPassword, hash);
|
||||
}
|
||||
}
|
||||
|
||||
return rawRequestParameters;
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ namespace Bit.Identity.Test.IdentityServer;
|
||||
public class SendPasswordRequestValidatorTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public void ValidateSendPassword_MissingPasswordHash_ReturnsInvalidRequest(
|
||||
public async Task ValidateSendPassword_MissingPasswordHash_ReturnsInvalidRequest(
|
||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
ResourcePassword resourcePassword,
|
||||
@@ -36,7 +36,7 @@ public class SendPasswordRequestValidatorTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
@@ -50,7 +50,7 @@ public class SendPasswordRequestValidatorTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant(
|
||||
public async Task ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant(
|
||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
ResourcePassword resourcePassword,
|
||||
@@ -70,7 +70,7 @@ public class SendPasswordRequestValidatorTests
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
@@ -84,7 +84,7 @@ public class SendPasswordRequestValidatorTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void ValidateSendPassword_PasswordHashMatches_ReturnsSuccess(
|
||||
public async Task ValidateSendPassword_PasswordHashMatches_ReturnsSuccess(
|
||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
ResourcePassword resourcePassword,
|
||||
@@ -104,7 +104,7 @@ public class SendPasswordRequestValidatorTests
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsError);
|
||||
@@ -113,7 +113,7 @@ public class SendPasswordRequestValidatorTests
|
||||
Assert.Equal(sendId, sub.GetSendId());
|
||||
|
||||
// Verify claims
|
||||
Assert.Contains(sub.Claims, c => c.Type == Claims.SendId && c.Value == sendId.ToString());
|
||||
Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString());
|
||||
Assert.Contains(sub.Claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString());
|
||||
|
||||
// Verify password hasher was called
|
||||
@@ -123,7 +123,7 @@ public class SendPasswordRequestValidatorTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher(
|
||||
public async Task ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher(
|
||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
ResourcePassword resourcePassword,
|
||||
@@ -142,7 +142,7 @@ public class SendPasswordRequestValidatorTests
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
@@ -155,7 +155,7 @@ public class SendPasswordRequestValidatorTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher(
|
||||
public async Task ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher(
|
||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
ResourcePassword resourcePassword,
|
||||
@@ -175,7 +175,7 @@ public class SendPasswordRequestValidatorTests
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
@@ -187,7 +187,7 @@ public class SendPasswordRequestValidatorTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant(
|
||||
public async Task ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant(
|
||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
ResourcePassword resourcePassword,
|
||||
@@ -208,7 +208,7 @@ public class SendPasswordRequestValidatorTests
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
@@ -221,7 +221,7 @@ public class SendPasswordRequestValidatorTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void ValidateSendPassword_SuccessResult_ContainsCorrectClaims(
|
||||
public async Task ValidateSendPassword_SuccessResult_ContainsCorrectClaims(
|
||||
SutProvider<SendPasswordRequestValidator> sutProvider,
|
||||
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
ResourcePassword resourcePassword,
|
||||
@@ -241,13 +241,13 @@ public class SendPasswordRequestValidatorTests
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId);
|
||||
var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsError);
|
||||
var sub = result.Subject;
|
||||
|
||||
var sendIdClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.SendId);
|
||||
var sendIdClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.SendAccessClaims.SendId);
|
||||
Assert.NotNull(sendIdClaim);
|
||||
Assert.Equal(sendId.ToString(), sendIdClaim.Value);
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@ using Bit.Core.Models.Data;
|
||||
using Bit.Core.NotificationCenter.Entities;
|
||||
using Bit.Core.NotificationCenter.Repositories;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Enums;
|
||||
using Bit.Core.Vault.Models.Data;
|
||||
using Bit.Core.Vault.Repositories;
|
||||
using Xunit;
|
||||
using CipherType = Bit.Core.Vault.Enums.CipherType;
|
||||
|
||||
namespace Bit.Infrastructure.IntegrationTest.Repositories;
|
||||
|
||||
@@ -975,6 +977,161 @@ public class CipherRepositoryTests
|
||||
Assert.Equal("new_attachments", updatedCipher2.Attachments);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task CreateAsync_vNext_WithFolders_Works(
|
||||
IUserRepository userRepository, ICipherRepository cipherRepository, IFolderRepository folderRepository)
|
||||
{
|
||||
// Arrange
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"{Guid.NewGuid()}@example.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var folder1 = new Folder { Id = CoreHelpers.GenerateComb(), UserId = user.Id, Name = "Test Folder 1" };
|
||||
var folder2 = new Folder { Id = CoreHelpers.GenerateComb(), UserId = user.Id, Name = "Test Folder 2" };
|
||||
var cipher1 = new Cipher { Id = CoreHelpers.GenerateComb(), Type = CipherType.Login, UserId = user.Id, Data = "" };
|
||||
var cipher2 = new Cipher { Id = CoreHelpers.GenerateComb(), Type = CipherType.SecureNote, UserId = user.Id, Data = "" };
|
||||
|
||||
// Act
|
||||
await cipherRepository.CreateAsync_vNext(
|
||||
userId: user.Id,
|
||||
ciphers: [cipher1, cipher2],
|
||||
folders: [folder1, folder2]);
|
||||
|
||||
// Assert
|
||||
var readCipher1 = await cipherRepository.GetByIdAsync(cipher1.Id);
|
||||
var readCipher2 = await cipherRepository.GetByIdAsync(cipher2.Id);
|
||||
Assert.NotNull(readCipher1);
|
||||
Assert.NotNull(readCipher2);
|
||||
|
||||
var readFolder1 = await folderRepository.GetByIdAsync(folder1.Id);
|
||||
var readFolder2 = await folderRepository.GetByIdAsync(folder2.Id);
|
||||
Assert.NotNull(readFolder1);
|
||||
Assert.NotNull(readFolder2);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task CreateAsync_vNext_WithCollectionsAndUsers_Works(
|
||||
IOrganizationRepository orgRepository,
|
||||
IOrganizationUserRepository orgUserRepository,
|
||||
ICollectionRepository collectionRepository,
|
||||
ICollectionCipherRepository collectionCipherRepository,
|
||||
ICipherRepository cipherRepository,
|
||||
IUserRepository userRepository)
|
||||
{
|
||||
// Arrange
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"{Guid.NewGuid()}@example.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var org = await orgRepository.CreateAsync(new Organization
|
||||
{
|
||||
Name = "Test Organization",
|
||||
BillingEmail = user.Email,
|
||||
Plan = "Test"
|
||||
});
|
||||
|
||||
var orgUser = await orgUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
UserId = user.Id,
|
||||
OrganizationId = org.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
Type = OrganizationUserType.Owner,
|
||||
});
|
||||
|
||||
var collection = new Collection { Id = CoreHelpers.GenerateComb(), Name = "Test Collection", OrganizationId = org.Id };
|
||||
var cipher = new Cipher { Id = CoreHelpers.GenerateComb(), Type = CipherType.Login, OrganizationId = org.Id, Data = "" };
|
||||
var collectionCipher = new CollectionCipher { CollectionId = collection.Id, CipherId = cipher.Id };
|
||||
var collectionUser = new CollectionUser
|
||||
{
|
||||
CollectionId = collection.Id,
|
||||
OrganizationUserId = orgUser.Id,
|
||||
HidePasswords = false,
|
||||
ReadOnly = false,
|
||||
Manage = true
|
||||
};
|
||||
|
||||
// Act
|
||||
await cipherRepository.CreateAsync_vNext(
|
||||
ciphers: [cipher],
|
||||
collections: [collection],
|
||||
collectionCiphers: [collectionCipher],
|
||||
collectionUsers: [collectionUser]);
|
||||
|
||||
// Assert
|
||||
var orgCiphers = await cipherRepository.GetManyByOrganizationIdAsync(org.Id);
|
||||
Assert.Contains(orgCiphers, c => c.Id == cipher.Id);
|
||||
|
||||
var collCiphers = await collectionCipherRepository.GetManyByOrganizationIdAsync(org.Id);
|
||||
Assert.Contains(collCiphers, cc => cc.CipherId == cipher.Id && cc.CollectionId == collection.Id);
|
||||
|
||||
var collectionsInOrg = await collectionRepository.GetManyByOrganizationIdAsync(org.Id);
|
||||
Assert.Contains(collectionsInOrg, c => c.Id == collection.Id);
|
||||
|
||||
var collectionUsers = await collectionRepository.GetManyUsersByIdAsync(collection.Id);
|
||||
var foundCollectionUser = collectionUsers.FirstOrDefault(cu => cu.Id == orgUser.Id);
|
||||
Assert.NotNull(foundCollectionUser);
|
||||
Assert.True(foundCollectionUser.Manage);
|
||||
Assert.False(foundCollectionUser.ReadOnly);
|
||||
Assert.False(foundCollectionUser.HidePasswords);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task UpdateCiphersAsync_vNext_Works(
|
||||
IUserRepository userRepository, ICipherRepository cipherRepository)
|
||||
{
|
||||
// Arrange
|
||||
var expectedNewType = CipherType.SecureNote;
|
||||
var expectedNewAttachments = "bulk_new_attachments";
|
||||
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"{Guid.NewGuid()}@example.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var c1 = new Cipher { Id = CoreHelpers.GenerateComb(), Type = CipherType.Login, UserId = user.Id, Data = "" };
|
||||
var c2 = new Cipher { Id = CoreHelpers.GenerateComb(), Type = CipherType.Login, UserId = user.Id, Data = "" };
|
||||
await cipherRepository.CreateAsync(
|
||||
userId: user.Id,
|
||||
ciphers: [c1, c2],
|
||||
folders: []);
|
||||
|
||||
c1.Type = expectedNewType;
|
||||
c2.Attachments = expectedNewAttachments;
|
||||
|
||||
// Act
|
||||
await cipherRepository.UpdateCiphersAsync_vNext(user.Id, [c1, c2]);
|
||||
|
||||
// Assert
|
||||
var updated1 = await cipherRepository.GetByIdAsync(c1.Id);
|
||||
Assert.NotNull(updated1);
|
||||
Assert.Equal(c1.Id, updated1.Id);
|
||||
Assert.Equal(expectedNewType, updated1.Type);
|
||||
Assert.Equal(c1.UserId, updated1.UserId);
|
||||
Assert.Equal(c1.Data, updated1.Data);
|
||||
Assert.Equal(c1.OrganizationId, updated1.OrganizationId);
|
||||
Assert.Equal(c1.Attachments, updated1.Attachments);
|
||||
|
||||
var updated2 = await cipherRepository.GetByIdAsync(c2.Id);
|
||||
Assert.NotNull(updated2);
|
||||
Assert.Equal(c2.Id, updated2.Id);
|
||||
Assert.Equal(c2.Type, updated2.Type);
|
||||
Assert.Equal(c2.UserId, updated2.UserId);
|
||||
Assert.Equal(c2.Data, updated2.Data);
|
||||
Assert.Equal(c2.OrganizationId, updated2.OrganizationId);
|
||||
Assert.Equal(expectedNewAttachments, updated2.Attachments);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task DeleteCipherWithSecurityTaskAsync_Works(
|
||||
IOrganizationRepository organizationRepository,
|
||||
|
||||
67
test/SharedWeb.Test/ActionNameOperationFilterTest.cs
Normal file
67
test/SharedWeb.Test/ActionNameOperationFilterTest.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using Bit.SharedWeb.Swagger;
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.OpenApi.Any;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace SharedWeb.Test;
|
||||
|
||||
public class ActionNameOperationFilterTest
|
||||
{
|
||||
[Fact]
|
||||
public void WithValidActionNameAddsActionNameExtensions()
|
||||
{
|
||||
// Arrange
|
||||
var operation = new OpenApiOperation();
|
||||
var actionDescriptor = new ActionDescriptor();
|
||||
actionDescriptor.RouteValues["action"] = "GetUsers";
|
||||
|
||||
var apiDescription = new ApiDescription
|
||||
{
|
||||
ActionDescriptor = actionDescriptor
|
||||
};
|
||||
|
||||
var context = new OperationFilterContext(apiDescription, null, null, null);
|
||||
var filter = new ActionNameOperationFilter();
|
||||
|
||||
// Act
|
||||
filter.Apply(operation, context);
|
||||
|
||||
// Assert
|
||||
Assert.True(operation.Extensions.ContainsKey("x-action-name"));
|
||||
Assert.True(operation.Extensions.ContainsKey("x-action-name-snake-case"));
|
||||
|
||||
var actionNameExt = operation.Extensions["x-action-name"] as OpenApiString;
|
||||
var actionNameSnakeCaseExt = operation.Extensions["x-action-name-snake-case"] as OpenApiString;
|
||||
|
||||
Assert.NotNull(actionNameExt);
|
||||
Assert.NotNull(actionNameSnakeCaseExt);
|
||||
Assert.Equal("GetUsers", actionNameExt.Value);
|
||||
Assert.Equal("get_users", actionNameSnakeCaseExt.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithMissingActionRouteValueDoesNotAddExtensions()
|
||||
{
|
||||
// Arrange
|
||||
var operation = new OpenApiOperation();
|
||||
var actionDescriptor = new ActionDescriptor();
|
||||
// Not setting the "action" route value at all
|
||||
|
||||
var apiDescription = new ApiDescription
|
||||
{
|
||||
ActionDescriptor = actionDescriptor
|
||||
};
|
||||
|
||||
var context = new OperationFilterContext(apiDescription, null, null, null);
|
||||
var filter = new ActionNameOperationFilter();
|
||||
|
||||
// Act
|
||||
filter.Apply(operation, context);
|
||||
|
||||
// Assert
|
||||
Assert.False(operation.Extensions.ContainsKey("x-action-name"));
|
||||
Assert.False(operation.Extensions.ContainsKey("x-action-name-snake-case"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using Bit.SharedWeb.Swagger;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace SharedWeb.Test;
|
||||
|
||||
public class UniqueOperationIdsController : ControllerBase
|
||||
{
|
||||
[HttpGet("unique-get")]
|
||||
public void UniqueGetAction() { }
|
||||
|
||||
[HttpPost("unique-post")]
|
||||
public void UniquePostAction() { }
|
||||
}
|
||||
|
||||
public class OverloadedOperationIdsController : ControllerBase
|
||||
{
|
||||
[HttpPut("another-duplicate")]
|
||||
public void AnotherDuplicateAction() { }
|
||||
|
||||
[HttpPatch("another-duplicate/{id}")]
|
||||
public void AnotherDuplicateAction(int id) { }
|
||||
}
|
||||
|
||||
public class MultipleHttpMethodsController : ControllerBase
|
||||
{
|
||||
[HttpGet("multi-method")]
|
||||
[HttpPost("multi-method")]
|
||||
[HttpPut("multi-method")]
|
||||
public void MultiMethodAction() { }
|
||||
}
|
||||
|
||||
public class CheckDuplicateOperationIdsDocumentFilterTest
|
||||
{
|
||||
[Fact]
|
||||
public void UniqueOperationIdsDoNotThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var (swaggerDoc, context) = SwaggerDocUtil.CreateDocFromControllers(typeof(UniqueOperationIdsController));
|
||||
var filter = new CheckDuplicateOperationIdsDocumentFilter();
|
||||
filter.Apply(swaggerDoc, context);
|
||||
// Act & Assert
|
||||
var exception = Record.Exception(() => filter.Apply(swaggerDoc, context));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateOperationIdsThrowInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var (swaggerDoc, context) = SwaggerDocUtil.CreateDocFromControllers(typeof(OverloadedOperationIdsController));
|
||||
var filter = new CheckDuplicateOperationIdsDocumentFilter(false);
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => filter.Apply(swaggerDoc, context));
|
||||
Assert.Contains("Duplicate operation IDs found in Swagger schema", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleHttpMethodsThrowInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var (swaggerDoc, context) = SwaggerDocUtil.CreateDocFromControllers(typeof(MultipleHttpMethodsController));
|
||||
var filter = new CheckDuplicateOperationIdsDocumentFilter(false);
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => filter.Apply(swaggerDoc, context));
|
||||
Assert.Contains("Duplicate operation IDs found in Swagger schema", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptySwaggerDocDoesNotThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var swaggerDoc = new OpenApiDocument { Paths = [] };
|
||||
var context = new DocumentFilterContext([], null, null);
|
||||
var filter = new CheckDuplicateOperationIdsDocumentFilter(false);
|
||||
|
||||
// Act & Assert
|
||||
var exception = Record.Exception(() => filter.Apply(swaggerDoc, context));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
|
||||
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
|
||||
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
|
||||
<PackageReference Include="xunit.runner.visualstudio"
|
||||
Version="$(XUnitRunnerVisualStudioVersion)">
|
||||
|
||||
85
test/SharedWeb.Test/SwaggerDocUtil.cs
Normal file
85
test/SharedWeb.Test/SwaggerDocUtil.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.AspNetCore.Mvc.ApplicationParts;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using NSubstitute;
|
||||
using Swashbuckle.AspNetCore.Swagger;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace SharedWeb.Test;
|
||||
|
||||
public class SwaggerDocUtil
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an OpenApiDocument and DocumentFilterContext from the specified controller type by setting up
|
||||
/// a minimal service collection and using the SwaggerProvider to generate the document.
|
||||
/// </summary>
|
||||
public static (OpenApiDocument, DocumentFilterContext) CreateDocFromControllers(params Type[] controllerTypes)
|
||||
{
|
||||
if (controllerTypes.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one controller type must be provided", nameof(controllerTypes));
|
||||
}
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton(Substitute.For<IWebHostEnvironment>());
|
||||
services.AddControllers()
|
||||
.ConfigureApplicationPartManager(manager =>
|
||||
{
|
||||
// Clear existing parts and feature providers
|
||||
manager.ApplicationParts.Clear();
|
||||
manager.FeatureProviders.Clear();
|
||||
|
||||
// Add a custom feature provider that only includes the specific controller types
|
||||
manager.FeatureProviders.Add(new MultipleControllerFeatureProvider(controllerTypes));
|
||||
|
||||
// Add assembly parts for all unique assemblies containing the controllers
|
||||
foreach (var assembly in controllerTypes.Select(t => t.Assembly).Distinct())
|
||||
{
|
||||
manager.ApplicationParts.Add(new AssemblyPart(assembly));
|
||||
}
|
||||
});
|
||||
services.AddSwaggerGen(config =>
|
||||
{
|
||||
config.SwaggerDoc("v1", new OpenApiInfo { Title = "Test API", Version = "v1" });
|
||||
config.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}");
|
||||
});
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
// Get API descriptions
|
||||
var allApiDescriptions = serviceProvider.GetRequiredService<IApiDescriptionGroupCollectionProvider>()
|
||||
.ApiDescriptionGroups.Items
|
||||
.SelectMany(group => group.Items)
|
||||
.ToList();
|
||||
|
||||
if (allApiDescriptions.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No API descriptions found for controller, ensure your controllers are defined correctly (public, not nested, inherit from ControllerBase, etc.)");
|
||||
}
|
||||
|
||||
// Generate the swagger document and context
|
||||
var document = serviceProvider.GetRequiredService<ISwaggerProvider>().GetSwagger("v1");
|
||||
var schemaGenerator = serviceProvider.GetRequiredService<ISchemaGenerator>();
|
||||
var context = new DocumentFilterContext(allApiDescriptions, schemaGenerator, new SchemaRepository());
|
||||
|
||||
return (document, context);
|
||||
}
|
||||
}
|
||||
|
||||
public class MultipleControllerFeatureProvider(params Type[] controllerTypes) : ControllerFeatureProvider
|
||||
{
|
||||
private readonly HashSet<Type> _allowedControllerTypes = [.. controllerTypes];
|
||||
|
||||
protected override bool IsController(TypeInfo typeInfo)
|
||||
{
|
||||
return _allowedControllerTypes.Contains(typeInfo.AsType())
|
||||
&& typeInfo.IsClass
|
||||
&& !typeInfo.IsAbstract
|
||||
&& typeof(ControllerBase).IsAssignableFrom(typeInfo);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user