1
0
mirror of https://github.com/bitwarden/server synced 2025-12-26 21:23:39 +00:00

Merge branch 'main' into billing/PM-24964/msp-unable-verfy-bank-account

This commit is contained in:
Alex Morask
2025-09-03 11:25:12 -05:00
97 changed files with 4274 additions and 1412 deletions

View File

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

View File

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

View File

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

View File

@@ -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>()

View File

@@ -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]

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

View File

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

View File

@@ -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)
{
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

@@ -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>())

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

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

View File

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

View File

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

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