mirror of
https://github.com/bitwarden/server
synced 2026-01-28 07:13:46 +00:00
[PM-30626] Fetch provided storage from Pricing Service when determining storage limit (#6845)
* Fetch provided storage from Pricing Service * Run dotnet format * Gbubemi's feedback
This commit is contained in:
@@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@@ -27,6 +28,7 @@ public class SendValidationService : ISendValidationService
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
|
||||
|
||||
|
||||
@@ -38,7 +40,7 @@ public class SendValidationService : ISendValidationService
|
||||
IUserService userService,
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
GlobalSettings globalSettings,
|
||||
|
||||
IPricingClient pricingClient,
|
||||
ICurrentContext currentContext)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
@@ -48,6 +50,7 @@ public class SendValidationService : ISendValidationService
|
||||
_userService = userService;
|
||||
_policyRequirementQuery = policyRequirementQuery;
|
||||
_globalSettings = globalSettings;
|
||||
_pricingClient = pricingClient;
|
||||
_currentContext = currentContext;
|
||||
}
|
||||
|
||||
@@ -123,10 +126,19 @@ public class SendValidationService : ISendValidationService
|
||||
}
|
||||
else
|
||||
{
|
||||
// Users that get access to file storage/premium from their organization get the default
|
||||
// 1 GB max storage.
|
||||
short limit = _globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : (short)1;
|
||||
storageBytesRemaining = user.StorageBytesRemaining(limit);
|
||||
// Users that get access to file storage/premium from their organization get storage
|
||||
// based on the current premium plan from the pricing service
|
||||
short provided;
|
||||
if (_globalSettings.SelfHosted)
|
||||
{
|
||||
provided = Constants.SelfHostedMaxStorageGb;
|
||||
}
|
||||
else
|
||||
{
|
||||
var premiumPlan = await _pricingClient.GetAvailablePremiumPlan();
|
||||
provided = (short)premiumPlan.Storage.Provided;
|
||||
}
|
||||
storageBytesRemaining = user.StorageBytesRemaining(provided);
|
||||
}
|
||||
}
|
||||
else if (send.OrganizationId.HasValue)
|
||||
|
||||
@@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Platform.Push;
|
||||
@@ -46,6 +47,7 @@ public class CipherService : ICipherService
|
||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
|
||||
public CipherService(
|
||||
ICipherRepository cipherRepository,
|
||||
@@ -65,7 +67,8 @@ public class CipherService : ICipherService
|
||||
IGetCipherPermissionsForUserQuery getCipherPermissionsForUserQuery,
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IFeatureService featureService)
|
||||
IFeatureService featureService,
|
||||
IPricingClient pricingClient)
|
||||
{
|
||||
_cipherRepository = cipherRepository;
|
||||
_folderRepository = folderRepository;
|
||||
@@ -85,6 +88,7 @@ public class CipherService : ICipherService
|
||||
_policyRequirementQuery = policyRequirementQuery;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_featureService = featureService;
|
||||
_pricingClient = pricingClient;
|
||||
}
|
||||
|
||||
public async Task SaveAsync(Cipher cipher, Guid savingUserId, DateTime? lastKnownRevisionDate,
|
||||
@@ -943,10 +947,19 @@ public class CipherService : ICipherService
|
||||
}
|
||||
else
|
||||
{
|
||||
// Users that get access to file storage/premium from their organization get the default
|
||||
// 1 GB max storage.
|
||||
storageBytesRemaining = user.StorageBytesRemaining(
|
||||
_globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : (short)1);
|
||||
// Users that get access to file storage/premium from their organization get storage
|
||||
// based on the current premium plan from the pricing service
|
||||
short provided;
|
||||
if (_globalSettings.SelfHosted)
|
||||
{
|
||||
provided = Constants.SelfHostedMaxStorageGb;
|
||||
}
|
||||
else
|
||||
{
|
||||
var premiumPlan = await _pricingClient.GetAvailablePremiumPlan();
|
||||
provided = (short)premiumPlan.Storage.Provided;
|
||||
}
|
||||
storageBytesRemaining = user.StorageBytesRemaining(provided);
|
||||
}
|
||||
}
|
||||
else if (cipher.OrganizationId.HasValue)
|
||||
|
||||
120
test/Core.Test/Tools/Services/SendValidationServiceTests.cs
Normal file
120
test/Core.Test/Tools/Services/SendValidationServiceTests.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Pricing.Premium;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Tools.Services;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class SendValidationServiceTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task StorageRemainingForSendAsync_OrgGrantedPremiumUser_UsesPricingService(
|
||||
SutProvider<SendValidationService> sutProvider,
|
||||
Send send,
|
||||
User user)
|
||||
{
|
||||
// Arrange
|
||||
send.UserId = user.Id;
|
||||
send.OrganizationId = null;
|
||||
send.Type = SendType.File;
|
||||
user.Premium = false;
|
||||
user.Storage = 1024L * 1024L * 1024L; // 1 GB used
|
||||
user.EmailVerified = true;
|
||||
|
||||
sutProvider.GetDependency<Bit.Core.Settings.GlobalSettings>().SelfHosted = false;
|
||||
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).Returns(user);
|
||||
sutProvider.GetDependency<IUserService>().CanAccessPremium(user).Returns(true);
|
||||
|
||||
var premiumPlan = new Plan
|
||||
{
|
||||
Storage = new Purchasable { Provided = 5 }
|
||||
};
|
||||
sutProvider.GetDependency<IPricingClient>().GetAvailablePremiumPlan().Returns(premiumPlan);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.StorageRemainingForSendAsync(send);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IPricingClient>().Received(1).GetAvailablePremiumPlan();
|
||||
Assert.True(result > 0);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StorageRemainingForSendAsync_IndividualPremium_DoesNotCallPricingService(
|
||||
SutProvider<SendValidationService> sutProvider,
|
||||
Send send,
|
||||
User user)
|
||||
{
|
||||
// Arrange
|
||||
send.UserId = user.Id;
|
||||
send.OrganizationId = null;
|
||||
send.Type = SendType.File;
|
||||
user.Premium = true;
|
||||
user.MaxStorageGb = 10;
|
||||
user.EmailVerified = true;
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).Returns(user);
|
||||
sutProvider.GetDependency<IUserService>().CanAccessPremium(user).Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.StorageRemainingForSendAsync(send);
|
||||
|
||||
// Assert - should NOT call pricing service for individual premium users
|
||||
await sutProvider.GetDependency<IPricingClient>().DidNotReceive().GetAvailablePremiumPlan();
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StorageRemainingForSendAsync_SelfHosted_DoesNotCallPricingService(
|
||||
SutProvider<SendValidationService> sutProvider,
|
||||
Send send,
|
||||
User user)
|
||||
{
|
||||
// Arrange
|
||||
send.UserId = user.Id;
|
||||
send.OrganizationId = null;
|
||||
send.Type = SendType.File;
|
||||
user.Premium = false;
|
||||
user.EmailVerified = true;
|
||||
|
||||
sutProvider.GetDependency<Bit.Core.Settings.GlobalSettings>().SelfHosted = true;
|
||||
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).Returns(user);
|
||||
sutProvider.GetDependency<IUserService>().CanAccessPremium(user).Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.StorageRemainingForSendAsync(send);
|
||||
|
||||
// Assert - should NOT call pricing service for self-hosted
|
||||
await sutProvider.GetDependency<IPricingClient>().DidNotReceive().GetAvailablePremiumPlan();
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StorageRemainingForSendAsync_OrgSend_DoesNotCallPricingService(
|
||||
SutProvider<SendValidationService> sutProvider,
|
||||
Send send,
|
||||
Organization org)
|
||||
{
|
||||
// Arrange
|
||||
send.UserId = null;
|
||||
send.OrganizationId = org.Id;
|
||||
send.Type = SendType.File;
|
||||
org.MaxStorageGb = 100;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(org.Id).Returns(org);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.StorageRemainingForSendAsync(send);
|
||||
|
||||
// Assert - should NOT call pricing service for org sends
|
||||
await sutProvider.GetDependency<IPricingClient>().DidNotReceive().GetAvailablePremiumPlan();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Pricing.Premium;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@@ -2228,10 +2230,6 @@ public class CipherServiceTests
|
||||
.PushSyncCiphersAsync(deletingUserId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[OrganizationCipherCustomize]
|
||||
[BitAutoData]
|
||||
@@ -2387,6 +2385,186 @@ public class CipherServiceTests
|
||||
ids.Count() == cipherIds.Length && ids.All(id => cipherIds.Contains(id))));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateAttachmentAsync_UserWithOrgGrantedPremium_UsesStorageFromPricingClient(
|
||||
SutProvider<CipherService> sutProvider, CipherDetails cipher, Guid savingUserId)
|
||||
{
|
||||
var stream = new MemoryStream(new byte[100]);
|
||||
var fileName = "test.txt";
|
||||
var key = "test-key";
|
||||
|
||||
// Setup cipher with user ownership
|
||||
cipher.UserId = savingUserId;
|
||||
cipher.OrganizationId = null;
|
||||
|
||||
// Setup user WITHOUT personal premium (Premium = false), but with org-granted premium access
|
||||
var user = new User
|
||||
{
|
||||
Id = savingUserId,
|
||||
Premium = false, // User does not have personal premium
|
||||
MaxStorageGb = null, // No personal storage allocation
|
||||
Storage = 0 // No storage used yet
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetByIdAsync(savingUserId)
|
||||
.Returns(user);
|
||||
|
||||
// User has premium access through their organization
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CanAccessPremium(user)
|
||||
.Returns(true);
|
||||
|
||||
// Mock GlobalSettings to indicate cloud (not self-hosted)
|
||||
sutProvider.GetDependency<Bit.Core.Settings.GlobalSettings>().SelfHosted = false;
|
||||
|
||||
// Mock the PricingClient to return a premium plan with 1 GB of storage
|
||||
var premiumPlan = new Plan
|
||||
{
|
||||
Name = "Premium",
|
||||
Available = true,
|
||||
Seat = new Purchasable { StripePriceId = "price_123", Price = 10, Provided = 1 },
|
||||
Storage = new Purchasable { StripePriceId = "price_456", Price = 4, Provided = 1 }
|
||||
};
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetAvailablePremiumPlan()
|
||||
.Returns(premiumPlan);
|
||||
|
||||
sutProvider.GetDependency<IAttachmentStorageService>()
|
||||
.UploadNewAttachmentAsync(Arg.Any<Stream>(), cipher, Arg.Any<CipherAttachment.MetaData>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
sutProvider.GetDependency<IAttachmentStorageService>()
|
||||
.ValidateFileAsync(cipher, Arg.Any<CipherAttachment.MetaData>(), Arg.Any<long>())
|
||||
.Returns((true, 100L));
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.UpdateAttachmentAsync(Arg.Any<CipherAttachment>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.ReplaceAsync(Arg.Any<CipherDetails>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, cipher.RevisionDate);
|
||||
|
||||
// Assert - PricingClient was called to get the premium plan storage
|
||||
await sutProvider.GetDependency<IPricingClient>().Received(1).GetAvailablePremiumPlan();
|
||||
|
||||
// Assert - Attachment was uploaded successfully
|
||||
await sutProvider.GetDependency<IAttachmentStorageService>().Received(1)
|
||||
.UploadNewAttachmentAsync(Arg.Any<Stream>(), cipher, Arg.Any<CipherAttachment.MetaData>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateAttachmentAsync_UserWithOrgGrantedPremium_ExceedsStorage_ThrowsBadRequest(
|
||||
SutProvider<CipherService> sutProvider, CipherDetails cipher, Guid savingUserId)
|
||||
{
|
||||
var stream = new MemoryStream(new byte[100]);
|
||||
var fileName = "test.txt";
|
||||
var key = "test-key";
|
||||
|
||||
// Setup cipher with user ownership
|
||||
cipher.UserId = savingUserId;
|
||||
cipher.OrganizationId = null;
|
||||
|
||||
// Setup user WITHOUT personal premium, with org-granted access, but storage is full
|
||||
var user = new User
|
||||
{
|
||||
Id = savingUserId,
|
||||
Premium = false,
|
||||
MaxStorageGb = null,
|
||||
Storage = 1073741824 // 1 GB already used (equals the provided storage)
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetByIdAsync(savingUserId)
|
||||
.Returns(user);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CanAccessPremium(user)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<Bit.Core.Settings.GlobalSettings>().SelfHosted = false;
|
||||
|
||||
// Premium plan provides 1 GB of storage
|
||||
var premiumPlan = new Plan
|
||||
{
|
||||
Name = "Premium",
|
||||
Available = true,
|
||||
Seat = new Purchasable { StripePriceId = "price_123", Price = 10, Provided = 1 },
|
||||
Storage = new Purchasable { StripePriceId = "price_456", Price = 4, Provided = 1 }
|
||||
};
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetAvailablePremiumPlan()
|
||||
.Returns(premiumPlan);
|
||||
|
||||
// Act & Assert - Should throw because storage is full
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, cipher.RevisionDate));
|
||||
Assert.Contains("Not enough storage available", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateAttachmentAsync_UserWithOrgGrantedPremium_SelfHosted_UsesConstantStorage(
|
||||
SutProvider<CipherService> sutProvider, CipherDetails cipher, Guid savingUserId)
|
||||
{
|
||||
var stream = new MemoryStream(new byte[100]);
|
||||
var fileName = "test.txt";
|
||||
var key = "test-key";
|
||||
|
||||
// Setup cipher with user ownership
|
||||
cipher.UserId = savingUserId;
|
||||
cipher.OrganizationId = null;
|
||||
|
||||
// Setup user WITHOUT personal premium, but with org-granted premium access
|
||||
var user = new User
|
||||
{
|
||||
Id = savingUserId,
|
||||
Premium = false,
|
||||
MaxStorageGb = null,
|
||||
Storage = 0
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetByIdAsync(savingUserId)
|
||||
.Returns(user);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CanAccessPremium(user)
|
||||
.Returns(true);
|
||||
|
||||
// Mock GlobalSettings to indicate self-hosted
|
||||
sutProvider.GetDependency<Bit.Core.Settings.GlobalSettings>().SelfHosted = true;
|
||||
|
||||
sutProvider.GetDependency<IAttachmentStorageService>()
|
||||
.UploadNewAttachmentAsync(Arg.Any<Stream>(), cipher, Arg.Any<CipherAttachment.MetaData>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
sutProvider.GetDependency<IAttachmentStorageService>()
|
||||
.ValidateFileAsync(cipher, Arg.Any<CipherAttachment.MetaData>(), Arg.Any<long>())
|
||||
.Returns((true, 100L));
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.UpdateAttachmentAsync(Arg.Any<CipherAttachment>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.ReplaceAsync(Arg.Any<CipherDetails>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, cipher.RevisionDate);
|
||||
|
||||
// Assert - PricingClient should NOT be called for self-hosted
|
||||
await sutProvider.GetDependency<IPricingClient>().DidNotReceive().GetAvailablePremiumPlan();
|
||||
|
||||
// Assert - Attachment was uploaded successfully
|
||||
await sutProvider.GetDependency<IAttachmentStorageService>().Received(1)
|
||||
.UploadNewAttachmentAsync(Arg.Any<Stream>(), cipher, Arg.Any<CipherAttachment.MetaData>());
|
||||
}
|
||||
|
||||
private async Task AssertNoActionsAsync(SutProvider<CipherService> sutProvider)
|
||||
{
|
||||
await sutProvider.GetDependency<ICipherRepository>().DidNotReceiveWithAnyArgs().GetManyOrganizationDetailsByOrganizationIdAsync(default);
|
||||
|
||||
Reference in New Issue
Block a user