mirror of
https://github.com/bitwarden/server
synced 2026-02-10 05:30:02 +00:00
Merge branch 'main' into PM-30247-Defect-Previously-archived-items-do-not-return-to-Archive-when-importing
This commit is contained in:
@@ -4,7 +4,6 @@ using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Api.IntegrationTest.Factories;
|
||||
using Bit.Api.IntegrationTest.Helpers;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
@@ -14,8 +13,6 @@ using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
|
||||
@@ -32,12 +29,6 @@ public class OrganizationUserControllerBulkRevokeTests : IClassFixture<ApiApplic
|
||||
public OrganizationUserControllerBulkRevokeTests(ApiApplicationFactory apiFactory)
|
||||
{
|
||||
_factory = apiFactory;
|
||||
_factory.SubstituteService<IFeatureService>(featureService =>
|
||||
{
|
||||
featureService
|
||||
.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2)
|
||||
.Returns(true);
|
||||
});
|
||||
_client = _factory.CreateClient();
|
||||
_loginHelper = new LoginHelper(_factory, _client);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -6,6 +6,7 @@ using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Platform.Push;
|
||||
@@ -114,4 +115,64 @@ public class CollectionsControllerTests : IClassFixture<ApiApplicationFactory>,
|
||||
Assert.NotEmpty(result.Item2.Groups);
|
||||
Assert.NotEmpty(result.Item2.Users);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ExcludesDefaultUserCollections_IncludesGroupsAndUsers()
|
||||
{
|
||||
// Arrange
|
||||
var collectionRepository = _factory.GetService<ICollectionRepository>();
|
||||
var groupRepository = _factory.GetService<IGroupRepository>();
|
||||
|
||||
var defaultCollection = new Collection
|
||||
{
|
||||
OrganizationId = _organization.Id,
|
||||
Name = "My Items",
|
||||
Type = CollectionType.DefaultUserCollection
|
||||
};
|
||||
await collectionRepository.CreateAsync(defaultCollection, null, null);
|
||||
|
||||
var group = await groupRepository.CreateAsync(new Group
|
||||
{
|
||||
OrganizationId = _organization.Id,
|
||||
Name = "Test Group",
|
||||
ExternalId = $"test-group-{Guid.NewGuid()}",
|
||||
});
|
||||
|
||||
var (_, user) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
||||
_factory,
|
||||
_organization.Id,
|
||||
OrganizationUserType.User);
|
||||
|
||||
var sharedCollection = await OrganizationTestHelpers.CreateCollectionAsync(
|
||||
_factory,
|
||||
_organization.Id,
|
||||
"Shared Collection with Access",
|
||||
externalId: "shared-collection-with-access",
|
||||
groups:
|
||||
[
|
||||
new CollectionAccessSelection { Id = group.Id, ReadOnly = false, HidePasswords = false, Manage = true }
|
||||
],
|
||||
users:
|
||||
[
|
||||
new CollectionAccessSelection { Id = user.Id, ReadOnly = true, HidePasswords = true, Manage = false }
|
||||
]);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetFromJsonAsync<ListResponseModel<CollectionResponseModel>>("public/collections");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
|
||||
Assert.DoesNotContain(response.Data, c => c.Id == defaultCollection.Id);
|
||||
|
||||
var collectionResponse = response.Data.First(c => c.Id == sharedCollection.Id);
|
||||
Assert.NotNull(collectionResponse.Groups);
|
||||
Assert.Single(collectionResponse.Groups);
|
||||
|
||||
var groupResponse = collectionResponse.Groups.First();
|
||||
Assert.Equal(group.Id, groupResponse.Id);
|
||||
Assert.False(groupResponse.ReadOnly);
|
||||
Assert.False(groupResponse.HidePasswords);
|
||||
Assert.True(groupResponse.Manage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -3,6 +3,8 @@ using Bit.Api.Billing.Models.Requests.Storage;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Licenses.Queries;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Billing.Subscriptions.Commands;
|
||||
using Bit.Core.Billing.Subscriptions.Queries;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -29,9 +31,11 @@ public class AccountBillingVNextControllerTests
|
||||
_sut = new AccountBillingVNextController(
|
||||
Substitute.For<Core.Billing.Payment.Commands.ICreateBitPayInvoiceForCreditCommand>(),
|
||||
Substitute.For<Core.Billing.Premium.Commands.ICreatePremiumCloudHostedSubscriptionCommand>(),
|
||||
Substitute.For<IGetBitwardenSubscriptionQuery>(),
|
||||
Substitute.For<Core.Billing.Payment.Queries.IGetCreditQuery>(),
|
||||
Substitute.For<Core.Billing.Payment.Queries.IGetPaymentMethodQuery>(),
|
||||
_getUserLicenseQuery,
|
||||
Substitute.For<IReinstateSubscriptionCommand>(),
|
||||
Substitute.For<Core.Billing.Payment.Commands.IUpdatePaymentMethodCommand>(),
|
||||
_updatePremiumStorageCommand,
|
||||
_upgradePremiumToOrganizationCommand);
|
||||
@@ -63,7 +67,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BillingCommandResult<None>(new None()));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -83,7 +87,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BadRequest(errorMessage));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -103,7 +107,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BadRequest(errorMessage));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -123,7 +127,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BadRequest(errorMessage));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -143,7 +147,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BadRequest(errorMessage));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -163,7 +167,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BadRequest(errorMessage));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -182,7 +186,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BillingCommandResult<None>(new None()));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -201,7 +205,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BillingCommandResult<None>(new None()));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -220,7 +224,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BillingCommandResult<None>(new None()));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -239,7 +243,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BillingCommandResult<None>(new None()));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
|
||||
@@ -8,8 +8,8 @@ using Bit.Api.Tools.Models.Request;
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
@@ -28,7 +28,6 @@ namespace Bit.Api.Test.Tools.Controllers;
|
||||
public class SendsControllerTests : IDisposable
|
||||
{
|
||||
private readonly SendsController _sut;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IUserService _userService;
|
||||
private readonly ISendRepository _sendRepository;
|
||||
private readonly INonAnonymousSendCommand _nonAnonymousSendCommand;
|
||||
@@ -37,6 +36,8 @@ public class SendsControllerTests : IDisposable
|
||||
private readonly ISendAuthorizationService _sendAuthorizationService;
|
||||
private readonly ISendFileStorageService _sendFileStorageService;
|
||||
private readonly ILogger<SendsController> _logger;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
|
||||
public SendsControllerTests()
|
||||
{
|
||||
@@ -47,8 +48,9 @@ public class SendsControllerTests : IDisposable
|
||||
_sendOwnerQuery = Substitute.For<ISendOwnerQuery>();
|
||||
_sendAuthorizationService = Substitute.For<ISendAuthorizationService>();
|
||||
_sendFileStorageService = Substitute.For<ISendFileStorageService>();
|
||||
_globalSettings = new GlobalSettings();
|
||||
_logger = Substitute.For<ILogger<SendsController>>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_pushNotificationService = Substitute.For<IPushNotificationService>();
|
||||
|
||||
_sut = new SendsController(
|
||||
_sendRepository,
|
||||
@@ -59,7 +61,8 @@ public class SendsControllerTests : IDisposable
|
||||
_sendOwnerQuery,
|
||||
_sendFileStorageService,
|
||||
_logger,
|
||||
_globalSettings
|
||||
_featureService,
|
||||
_pushNotificationService
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,8 +99,8 @@ public class SendsControllerTests : IDisposable
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var expected = "You cannot have a Send with a deletion date that far " +
|
||||
"into the future. Adjust the Deletion Date to a value less than 31 days from now " +
|
||||
"and try again.";
|
||||
"into the future. Adjust the Deletion Date to a value less than 31 days from now " +
|
||||
"and try again.";
|
||||
var request = new SendRequestModel() { DeletionDate = now.AddDays(32) };
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.Post(request));
|
||||
@@ -109,9 +112,10 @@ public class SendsControllerTests : IDisposable
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var expected = "You cannot have a Send with a deletion date that far " +
|
||||
"into the future. Adjust the Deletion Date to a value less than 31 days from now " +
|
||||
"and try again.";
|
||||
var request = new SendRequestModel() { Type = SendType.File, FileLength = 1024L, DeletionDate = now.AddDays(32) };
|
||||
"into the future. Adjust the Deletion Date to a value less than 31 days from now " +
|
||||
"and try again.";
|
||||
var request =
|
||||
new SendRequestModel() { Type = SendType.File, FileLength = 1024L, DeletionDate = now.AddDays(32) };
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostFile(request));
|
||||
Assert.Equal(expected, exception.Message);
|
||||
@@ -409,7 +413,8 @@ public class SendsControllerTests : IDisposable
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemovePassword_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId)
|
||||
public async Task PutRemovePassword_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId,
|
||||
Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
var existingSend = new Send
|
||||
@@ -753,4 +758,683 @@ public class SendsControllerTests : IDisposable
|
||||
s.Password == null &&
|
||||
s.Emails == null));
|
||||
}
|
||||
|
||||
#region Authenticated Access Endpoints
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithValidSend_ReturnsSendAccessResponse(Guid sendId, User creator)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = creator.Id,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
HideEmail = false,
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
_userService.GetUserByIdAsync(creator.Id).Returns(creator);
|
||||
|
||||
var result = await _sut.AccessUsingAuth();
|
||||
|
||||
Assert.NotNull(result);
|
||||
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||
var response = Assert.IsType<SendAccessResponseModel>(objectResult.Value);
|
||||
Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id);
|
||||
Assert.Equal(creator.Email, response.CreatorIdentifier);
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
await _userService.Received(1).GetUserByIdAsync(creator.Id);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithHideEmail_DoesNotIncludeCreatorIdentifier(Guid sendId, User creator)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = creator.Id,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
HideEmail = true,
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
var result = await _sut.AccessUsingAuth();
|
||||
|
||||
Assert.NotNull(result);
|
||||
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||
var response = Assert.IsType<SendAccessResponseModel>(objectResult.Value);
|
||||
Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id);
|
||||
Assert.Null(response.CreatorIdentifier);
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
await _userService.DidNotReceive().GetUserByIdAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithNoUserId_DoesNotIncludeCreatorIdentifier(Guid sendId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = null,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
HideEmail = false,
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
var result = await _sut.AccessUsingAuth();
|
||||
|
||||
Assert.NotNull(result);
|
||||
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||
var response = Assert.IsType<SendAccessResponseModel>(objectResult.Value);
|
||||
Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id);
|
||||
Assert.Null(response.CreatorIdentifier);
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
await _userService.DidNotReceive().GetUserByIdAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithNonExistentSend_ThrowsBadRequestException(Guid sendId)
|
||||
{
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns((Send)null);
|
||||
|
||||
var exception =
|
||||
await Assert.ThrowsAsync<BadRequestException>(() => _sut.AccessUsingAuth());
|
||||
|
||||
Assert.Equal("Could not locate send", exception.Message);
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithFileSend_ReturnsCorrectResponse(Guid sendId, User creator)
|
||||
{
|
||||
var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = "file-123", Size = 2048 };
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = creator.Id,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(fileData),
|
||||
HideEmail = false,
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
_userService.GetUserByIdAsync(creator.Id).Returns(creator);
|
||||
|
||||
var result = await _sut.AccessUsingAuth();
|
||||
|
||||
Assert.NotNull(result);
|
||||
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||
var response = Assert.IsType<SendAccessResponseModel>(objectResult.Value);
|
||||
Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id);
|
||||
Assert.Equal(SendType.File, response.Type);
|
||||
Assert.NotNull(response.File);
|
||||
Assert.Equal("file-123", response.File.Id);
|
||||
Assert.Equal(creator.Email, response.CreatorIdentifier);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithValidFileId_ReturnsDownloadUrl(
|
||||
Guid sendId, string fileId, string expectedUrl)
|
||||
{
|
||||
var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = fileId, Size = 2048 };
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(fileData),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
_sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId).Returns(expectedUrl);
|
||||
|
||||
var result = await _sut.GetSendFileDownloadDataUsingAuth(fileId);
|
||||
|
||||
Assert.NotNull(result);
|
||||
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||
var response = Assert.IsType<SendFileDownloadDataResponseModel>(objectResult.Value);
|
||||
Assert.Equal(fileId, response.Id);
|
||||
Assert.Equal(expectedUrl, response.Url);
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
await _sendFileStorageService.Received(1).GetSendFileDownloadUrlAsync(send, fileId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithNonExistentSend_ThrowsBadRequestException(
|
||||
Guid sendId, string fileId)
|
||||
{
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns((Send)null);
|
||||
|
||||
var exception =
|
||||
await Assert.ThrowsAsync<BadRequestException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
|
||||
|
||||
Assert.Equal("Could not locate send", exception.Message);
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
await _sendFileStorageService.DidNotReceive()
|
||||
.GetSendFileDownloadUrlAsync(Arg.Any<Send>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithTextSend_StillReturnsResponse(
|
||||
Guid sendId, string fileId, string expectedUrl)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
_sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId).Returns(expectedUrl);
|
||||
|
||||
var result = await _sut.GetSendFileDownloadDataUsingAuth(fileId);
|
||||
|
||||
Assert.NotNull(result);
|
||||
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||
var response = Assert.IsType<SendFileDownloadDataResponseModel>(objectResult.Value);
|
||||
Assert.Equal(fileId, response.Id);
|
||||
Assert.Equal(expectedUrl, response.Url);
|
||||
}
|
||||
|
||||
#region AccessUsingAuth Validation Tests
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithExpiredSend_ThrowsNotFoundException(Guid sendId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = Guid.NewGuid(),
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = DateTime.UtcNow.AddDays(-1), // Expired yesterday
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithDeletedSend_ThrowsNotFoundException(Guid sendId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = Guid.NewGuid(),
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(-1), // Should have been deleted yesterday
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithDisabledSend_ThrowsNotFoundException(Guid sendId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = Guid.NewGuid(),
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = true, // Disabled
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithAccessCountExceeded_ThrowsNotFoundException(Guid sendId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = Guid.NewGuid(),
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 5,
|
||||
MaxAccessCount = 5 // Limit reached
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSendFileDownloadDataUsingAuth Validation Tests
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithExpiredSend_ThrowsNotFoundException(
|
||||
Guid sendId, string fileId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = DateTime.UtcNow.AddDays(-1), // Expired
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithDeletedSend_ThrowsNotFoundException(
|
||||
Guid sendId, string fileId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(-1), // Deleted
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithDisabledSend_ThrowsNotFoundException(
|
||||
Guid sendId, string fileId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = true, // Disabled
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithAccessCountExceeded_ThrowsNotFoundException(
|
||||
Guid sendId, string fileId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 10,
|
||||
MaxAccessCount = 10 // Limit reached
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
|
||||
#region PutRemoveAuth Tests
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_WithPasswordProtectedSend_RemovesPasswordAndSetsAuthTypeNone(Guid userId,
|
||||
Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
var existingSend = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = userId,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
Password = "hashed-password",
|
||||
Emails = null,
|
||||
AuthType = AuthType.Password
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
|
||||
|
||||
var result = await _sut.PutRemoveAuth(sendId.ToString());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(sendId, result.Id);
|
||||
Assert.Equal(AuthType.None, result.AuthType);
|
||||
Assert.Null(result.Password);
|
||||
Assert.Null(result.Emails);
|
||||
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
|
||||
s.Id == sendId &&
|
||||
s.Password == null &&
|
||||
s.Emails == null &&
|
||||
s.AuthType == AuthType.None));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_WithEmailProtectedSend_RemovesEmailsAndSetsAuthTypeNone(Guid userId, Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
var existingSend = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = userId,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
Password = null,
|
||||
Emails = "test@example.com,user@example.com",
|
||||
AuthType = AuthType.Email
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
|
||||
|
||||
var result = await _sut.PutRemoveAuth(sendId.ToString());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(sendId, result.Id);
|
||||
Assert.Equal(AuthType.None, result.AuthType);
|
||||
Assert.Null(result.Password);
|
||||
Assert.Null(result.Emails);
|
||||
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
|
||||
s.Id == sendId &&
|
||||
s.Password == null &&
|
||||
s.Emails == null &&
|
||||
s.AuthType == AuthType.None));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_WithSendAlreadyHavingNoAuth_StillSucceeds(Guid userId, Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
var existingSend = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = userId,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
Password = null,
|
||||
Emails = null,
|
||||
AuthType = AuthType.None
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
|
||||
|
||||
var result = await _sut.PutRemoveAuth(sendId.ToString());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(sendId, result.Id);
|
||||
Assert.Equal(AuthType.None, result.AuthType);
|
||||
Assert.Null(result.Password);
|
||||
Assert.Null(result.Emails);
|
||||
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
|
||||
s.Id == sendId &&
|
||||
s.Password == null &&
|
||||
s.Emails == null &&
|
||||
s.AuthType == AuthType.None));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_WithFileSend_RemovesAuthAndPreservesFileData(Guid userId, Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = "file-123", Size = 2048 };
|
||||
var existingSend = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = userId,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(fileData),
|
||||
Password = "hashed-password",
|
||||
Emails = null,
|
||||
AuthType = AuthType.Password
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
|
||||
|
||||
var result = await _sut.PutRemoveAuth(sendId.ToString());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(sendId, result.Id);
|
||||
Assert.Equal(AuthType.None, result.AuthType);
|
||||
Assert.Equal(SendType.File, result.Type);
|
||||
Assert.NotNull(result.File);
|
||||
Assert.Equal("file-123", result.File.Id);
|
||||
Assert.Null(result.Password);
|
||||
Assert.Null(result.Emails);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_WithNonExistentSend_ThrowsNotFoundException(Guid userId, Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns((Send)null);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PutRemoveAuth(sendId.ToString()));
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any<Send>());
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
var existingSend = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = otherUserId,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
Password = "hashed-password",
|
||||
AuthType = AuthType.Password
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PutRemoveAuth(sendId.ToString()));
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any<Send>());
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_WithNullUserId_ThrowsInvalidOperationException(Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns((Guid?)null);
|
||||
|
||||
var exception =
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.PutRemoveAuth(sendId.ToString()));
|
||||
|
||||
Assert.Equal("User ID not found", exception.Message);
|
||||
await _sendRepository.DidNotReceive().GetByIdAsync(Arg.Any<Guid>());
|
||||
await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any<Send>());
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_WithSendHavingBothPasswordAndEmails_RemovesBoth(Guid userId, Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
var existingSend = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = userId,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
Password = "hashed-password",
|
||||
Emails = "test@example.com",
|
||||
AuthType = AuthType.Password
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
|
||||
|
||||
var result = await _sut.PutRemoveAuth(sendId.ToString());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(sendId, result.Id);
|
||||
Assert.Equal(AuthType.None, result.AuthType);
|
||||
Assert.Null(result.Password);
|
||||
Assert.Null(result.Emails);
|
||||
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
|
||||
s.Id == sendId &&
|
||||
s.Password == null &&
|
||||
s.Emails == null &&
|
||||
s.AuthType == AuthType.None));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_PreservesOtherSendProperties(Guid userId, Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
var deletionDate = DateTime.UtcNow.AddDays(7);
|
||||
var expirationDate = DateTime.UtcNow.AddDays(3);
|
||||
var existingSend = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = userId,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
Password = "hashed-password",
|
||||
AuthType = AuthType.Password,
|
||||
Key = "encryption-key",
|
||||
MaxAccessCount = 10,
|
||||
AccessCount = 3,
|
||||
DeletionDate = deletionDate,
|
||||
ExpirationDate = expirationDate,
|
||||
Disabled = false,
|
||||
HideEmail = true
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
|
||||
|
||||
var result = await _sut.PutRemoveAuth(sendId.ToString());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(sendId, result.Id);
|
||||
Assert.Equal(AuthType.None, result.AuthType);
|
||||
// Verify other properties are preserved
|
||||
Assert.Equal("encryption-key", result.Key);
|
||||
Assert.Equal(10, result.MaxAccessCount);
|
||||
Assert.Equal(3, result.AccessCount);
|
||||
Assert.Equal(deletionDate, result.DeletionDate);
|
||||
Assert.Equal(expirationDate, result.ExpirationDate);
|
||||
Assert.False(result.Disabled);
|
||||
Assert.True(result.HideEmail);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static ClaimsPrincipal CreateUserWithSendIdClaim(Guid sendId)
|
||||
{
|
||||
var claims = new List<Claim> { new Claim("send_id", sendId.ToString()) };
|
||||
var identity = new ClaimsIdentity(claims, "TestAuth");
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
private static ControllerContext CreateControllerContextWithUser(ClaimsPrincipal user)
|
||||
{
|
||||
return new ControllerContext { HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext { User = user } };
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Test.Billing.Mocks;
|
||||
using Bit.Core.Test.Billing.Mocks.Plans;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json.Linq;
|
||||
@@ -654,6 +655,8 @@ public class SubscriptionUpdatedHandlerTests
|
||||
var plan = new Enterprise2023Plan(true);
|
||||
_pricingClient.GetPlanOrThrow(organization.PlanType)
|
||||
.Returns(plan);
|
||||
_pricingClient.ListPlans()
|
||||
.Returns(MockPlans.Plans);
|
||||
|
||||
var parsedEvent = new Event
|
||||
{
|
||||
@@ -693,6 +696,92 @@ public class SubscriptionUpdatedHandlerTests
|
||||
await _stripeFacade.Received(1).DeleteCustomerDiscount(subscription.CustomerId);
|
||||
await _stripeFacade.Received(1).DeleteSubscriptionDiscount(subscription.Id);
|
||||
}
|
||||
[Fact]
|
||||
public async Task
|
||||
HandleAsync_WhenUpgradingPlan_AndPreviousPlanHasSecretsManagerTrial_AndCurrentPlanHasSecretsManagerTrial_DoesNotRemovePasswordManagerCoupon()
|
||||
{
|
||||
// Arrange
|
||||
var organizationId = Guid.NewGuid();
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
Status = StripeSubscriptionStatus.Active,
|
||||
CustomerId = "cus_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(10),
|
||||
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
|
||||
},
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(10),
|
||||
Plan = new Plan { Id = "secrets-manager-enterprise-seat-annually" }
|
||||
}
|
||||
]
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Balance = 0,
|
||||
Discount = new Discount { Coupon = new Coupon { Id = "sm-standalone" } }
|
||||
},
|
||||
Discounts = [new Discount { Coupon = new Coupon { Id = "sm-standalone" } }],
|
||||
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } }
|
||||
};
|
||||
|
||||
// Note: The organization plan is still the previous plan because the subscription is updated before the organization is updated
|
||||
var organization = new Organization { Id = organizationId, PlanType = PlanType.TeamsAnnually2023 };
|
||||
|
||||
var plan = new Teams2023Plan(true);
|
||||
_pricingClient.GetPlanOrThrow(organization.PlanType)
|
||||
.Returns(plan);
|
||||
_pricingClient.ListPlans()
|
||||
.Returns(MockPlans.Plans);
|
||||
|
||||
var parsedEvent = new Event
|
||||
{
|
||||
Data = new EventData
|
||||
{
|
||||
Object = subscription,
|
||||
PreviousAttributes = JObject.FromObject(new
|
||||
{
|
||||
items = new
|
||||
{
|
||||
data = new[]
|
||||
{
|
||||
new { plan = new { id = "secrets-manager-teams-seat-annually" } },
|
||||
}
|
||||
},
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { Plan = new Stripe.Plan { Id = "secrets-manager-teams-seat-annually" } },
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
|
||||
.Returns(subscription);
|
||||
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(organizationId, null, null));
|
||||
|
||||
_organizationRepository.GetByIdAsync(organizationId)
|
||||
.Returns(organization);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.DidNotReceive().DeleteCustomerDiscount(subscription.CustomerId);
|
||||
await _stripeFacade.DidNotReceive().DeleteSubscriptionDiscount(subscription.Id);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetNonActiveSubscriptions))]
|
||||
|
||||
@@ -280,7 +280,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
email.ToEmails.Contains("user@example.com") &&
|
||||
email.Subject == "Your Bitwarden Premium renewal is updating" &&
|
||||
email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.DiscountedMonthlyRenewalPrice == (discountedPrice / 12).ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.DiscountedAnnualRenewalPrice == discountedPrice.ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.DiscountAmount == $"{coupon.PercentOff}%"
|
||||
));
|
||||
}
|
||||
@@ -2436,7 +2436,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
email.Subject == "Your Bitwarden Premium renewal is updating" &&
|
||||
email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.DiscountAmount == "30%" &&
|
||||
email.View.DiscountedMonthlyRenewalPrice == (expectedDiscountedPrice / 12).ToString("C", new CultureInfo("en-US"))
|
||||
email.View.DiscountedAnnualRenewalPrice == expectedDiscountedPrice.ToString("C", new CultureInfo("en-US"))
|
||||
));
|
||||
|
||||
await _mailService.DidNotReceive().SendInvoiceUpcoming(
|
||||
|
||||
@@ -26,6 +26,7 @@ public class SutProvider<TSut> : ISutProvider
|
||||
|
||||
public TSut Sut { get; private set; }
|
||||
public Type SutType => typeof(TSut);
|
||||
public IFixture Fixture => _fixture;
|
||||
|
||||
public SutProvider() : this(new Fixture()) { }
|
||||
|
||||
@@ -65,6 +66,19 @@ public class SutProvider<TSut> : ISutProvider
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates and registers a dependency to be injected when the sut is created.
|
||||
/// </summary>
|
||||
/// <typeparam name="TDep">The Dependency type to create</typeparam>
|
||||
/// <param name="parameterName">The (optional) parameter name to register the dependency under</param>
|
||||
/// <returns>The created dependency value</returns>
|
||||
public TDep CreateDependency<TDep>(string parameterName = "")
|
||||
{
|
||||
var dependency = _fixture.Create<TDep>();
|
||||
SetDependency(dependency, parameterName);
|
||||
return dependency;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a dependency of the sut. Can only be called after the dependency has been set, either explicitly with
|
||||
/// <see cref="SetDependency{T}"/> or automatically with <see cref="Create"/>.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Bit.Test.Common</RootNamespace>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Test.Common.Helpers;
|
||||
using Xunit;
|
||||
|
||||
@@ -115,4 +118,105 @@ public class OrganizationTests
|
||||
|
||||
Assert.True(organization.UseDisableSmAdsForUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromLicense_AppliesAllLicenseProperties()
|
||||
{
|
||||
// This test ensures that when a new property is added to OrganizationLicense,
|
||||
// it is also applied to the Organization in UpdateFromLicense().
|
||||
// This is the fourth step in the license synchronization pipeline:
|
||||
// Property → Constant → Claim → Extraction → Application
|
||||
|
||||
// 1. Get all public properties from OrganizationLicense
|
||||
var licenseProperties = typeof(OrganizationLicense)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Select(p => p.Name)
|
||||
.ToHashSet();
|
||||
|
||||
// 2. Define properties that don't need to be applied to Organization
|
||||
var excludedProperties = new HashSet<string>
|
||||
{
|
||||
// Internal/computed properties
|
||||
"SignatureBytes", // Computed from Signature property
|
||||
"ValidLicenseVersion", // Internal property, not serialized
|
||||
"CurrentLicenseFileVersion", // Constant field, not an instance property
|
||||
"Hash", // Signature-related, not applied to org
|
||||
"Signature", // Signature-related, not applied to org
|
||||
"Token", // The JWT itself, not applied to org
|
||||
"Version", // License version, not stored on org
|
||||
|
||||
// Properties intentionally excluded from UpdateFromLicense
|
||||
"Id", // Self-hosted org has its own unique Guid
|
||||
"MaxStorageGb", // Not enforced for self-hosted (per comment in UpdateFromLicense)
|
||||
|
||||
// Properties not stored on Organization model
|
||||
"LicenseType", // Not a property on Organization
|
||||
"InstallationId", // Not a property on Organization
|
||||
"Issued", // Not a property on Organization
|
||||
"Refresh", // Not a property on Organization
|
||||
"ExpirationWithoutGracePeriod", // Not a property on Organization
|
||||
"Trial", // Not a property on Organization
|
||||
"Expires", // Mapped to ExpirationDate on Organization (different name)
|
||||
|
||||
// Deprecated properties not applied
|
||||
"LimitCollectionCreationDeletion", // Deprecated, not applied
|
||||
"AllowAdminAccessToAllCollectionItems", // Deprecated, not applied
|
||||
};
|
||||
|
||||
// 3. Get properties that should be applied
|
||||
var propertiesThatShouldBeApplied = licenseProperties
|
||||
.Except(excludedProperties)
|
||||
.ToHashSet();
|
||||
|
||||
// 4. Read Organization.UpdateFromLicense source code
|
||||
var organizationSourcePath = Path.Combine(
|
||||
Directory.GetCurrentDirectory(),
|
||||
"..", "..", "..", "..", "..", "src", "Core", "AdminConsole", "Entities", "Organization.cs");
|
||||
var sourceCode = File.ReadAllText(organizationSourcePath);
|
||||
|
||||
// 5. Find all property assignments in UpdateFromLicense method
|
||||
// Pattern matches: PropertyName = license.PropertyName
|
||||
// This regex looks for assignments like "Name = license.Name" or "ExpirationDate = license.Expires"
|
||||
var assignmentPattern = @"(\w+)\s*=\s*license\.(\w+)";
|
||||
var matches = Regex.Matches(sourceCode, assignmentPattern);
|
||||
|
||||
var appliedProperties = new HashSet<string>();
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
// Get the license property name (right side of assignment)
|
||||
var licensePropertyName = match.Groups[2].Value;
|
||||
appliedProperties.Add(licensePropertyName);
|
||||
}
|
||||
|
||||
// Special case: Expires is mapped to ExpirationDate
|
||||
if (appliedProperties.Contains("Expires"))
|
||||
{
|
||||
appliedProperties.Add("Expires"); // Already added, but being explicit
|
||||
}
|
||||
|
||||
// 6. Find missing applications
|
||||
var missingApplications = propertiesThatShouldBeApplied
|
||||
.Except(appliedProperties)
|
||||
.OrderBy(p => p)
|
||||
.ToList();
|
||||
|
||||
// 7. Build error message with guidance
|
||||
var errorMessage = "";
|
||||
if (missingApplications.Any())
|
||||
{
|
||||
errorMessage = $"The following OrganizationLicense properties are NOT applied to Organization in UpdateFromLicense():\n";
|
||||
errorMessage += string.Join("\n", missingApplications.Select(p => $" - {p}"));
|
||||
errorMessage += "\n\nPlease add the following lines to Organization.UpdateFromLicense():\n";
|
||||
foreach (var prop in missingApplications)
|
||||
{
|
||||
errorMessage += $" {prop} = license.{prop};\n";
|
||||
}
|
||||
errorMessage += "\nNote: If the property maps to a different name on Organization (like Expires → ExpirationDate), adjust accordingly.";
|
||||
}
|
||||
|
||||
// 8. Assert - if this fails, the error message guides the developer to add the application
|
||||
Assert.True(
|
||||
!missingApplications.Any(),
|
||||
$"\n{errorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ using Bit.Core.AdminConsole.Utilities.v2;
|
||||
using Bit.Core.AdminConsole.Utilities.v2.Validation;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@@ -204,14 +203,10 @@ public class AutomaticallyConfirmUsersCommandTests
|
||||
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync(
|
||||
Arg.Is<Collection>(c =>
|
||||
c.OrganizationId == organization.Id &&
|
||||
c.Name == defaultCollectionName &&
|
||||
c.Type == CollectionType.DefaultUserCollection),
|
||||
Arg.Is<IEnumerable<CollectionAccessSelection>>(groups => groups == null),
|
||||
Arg.Is<IEnumerable<CollectionAccessSelection>>(access =>
|
||||
access.FirstOrDefault(x => x.Id == organizationUser.Id && x.Manage) != null));
|
||||
.CreateDefaultCollectionsAsync(
|
||||
organization.Id,
|
||||
Arg.Is<IEnumerable<Guid>>(ids => ids.Single() == organizationUser.Id),
|
||||
defaultCollectionName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -253,9 +248,7 @@ public class AutomaticallyConfirmUsersCommandTests
|
||||
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.DidNotReceive()
|
||||
.CreateAsync(Arg.Any<Collection>(),
|
||||
Arg.Any<IEnumerable<CollectionAccessSelection>>(),
|
||||
Arg.Any<IEnumerable<CollectionAccessSelection>>());
|
||||
.CreateDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -291,9 +284,7 @@ public class AutomaticallyConfirmUsersCommandTests
|
||||
|
||||
var collectionException = new Exception("Collection creation failed");
|
||||
sutProvider.GetDependency<ICollectionRepository>()
|
||||
.CreateAsync(Arg.Any<Collection>(),
|
||||
Arg.Any<IEnumerable<CollectionAccessSelection>>(),
|
||||
Arg.Any<IEnumerable<CollectionAccessSelection>>())
|
||||
.CreateDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>())
|
||||
.ThrowsAsync(collectionException);
|
||||
|
||||
// Act
|
||||
|
||||
@@ -13,7 +13,6 @@ using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
@@ -493,15 +492,10 @@ public class ConfirmOrganizationUserCommandTests
|
||||
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync(
|
||||
Arg.Is<Collection>(c =>
|
||||
c.Name == collectionName &&
|
||||
c.OrganizationId == organization.Id &&
|
||||
c.Type == CollectionType.DefaultUserCollection),
|
||||
Arg.Any<IEnumerable<CollectionAccessSelection>>(),
|
||||
Arg.Is<IEnumerable<CollectionAccessSelection>>(cu =>
|
||||
cu.Single().Id == orgUser.Id &&
|
||||
cu.Single().Manage));
|
||||
.CreateDefaultCollectionsAsync(
|
||||
organization.Id,
|
||||
Arg.Is<IEnumerable<Guid>>(ids => ids.Single() == orgUser.Id),
|
||||
collectionName);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -522,7 +516,7 @@ public class ConfirmOrganizationUserCommandTests
|
||||
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.DidNotReceive()
|
||||
.UpsertDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
|
||||
.CreateDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -539,24 +533,15 @@ public class ConfirmOrganizationUserCommandTests
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
|
||||
sutProvider.GetDependency<IUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
|
||||
|
||||
var policyDetails = new PolicyDetails
|
||||
{
|
||||
OrganizationId = org.Id,
|
||||
OrganizationUserId = orgUser.Id,
|
||||
IsProvider = false,
|
||||
OrganizationUserStatus = orgUser.Status,
|
||||
OrganizationUserType = orgUser.Type,
|
||||
PolicyType = PolicyType.OrganizationDataOwnership
|
||||
};
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||
.GetAsync<OrganizationDataOwnershipPolicyRequirement>(orgUser.UserId!.Value)
|
||||
.Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [policyDetails]));
|
||||
.Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, []));
|
||||
|
||||
await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName);
|
||||
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.DidNotReceive()
|
||||
.UpsertDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
|
||||
.CreateDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
|
||||
@@ -715,6 +715,39 @@ public class RestoreOrganizationUserCommandTests
|
||||
Arg.Is<OrganizationUserStatusType>(x => x != OrganizationUserStatusType.Revoked));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RestoreUser_InvitedUserInFreeOrganization_Success(
|
||||
Organization organization,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
|
||||
[OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser,
|
||||
SutProvider<RestoreOrganizationUserCommand> sutProvider)
|
||||
{
|
||||
organization.PlanType = PlanType.Free;
|
||||
organizationUser.UserId = null;
|
||||
organizationUser.Key = null;
|
||||
organizationUser.Status = OrganizationUserStatusType.Revoked;
|
||||
|
||||
RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
|
||||
{
|
||||
Sponsored = 0,
|
||||
Users = 1
|
||||
});
|
||||
|
||||
await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Invited);
|
||||
await sutProvider.GetDependency<IEventService>()
|
||||
.Received(1)
|
||||
.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
|
||||
await sutProvider.GetDependency<IPushNotificationService>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.PushSyncOrgKeysAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RestoreUsers_Success(Organization organization,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
|
||||
|
||||
@@ -283,7 +283,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Invited,
|
||||
UserId = Guid.NewGuid(),
|
||||
UserId = null,
|
||||
Email = "invited@example.com"
|
||||
};
|
||||
|
||||
@@ -302,6 +302,56 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_MixedUsersWithNullUserId_HandlesCorrectly(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
Guid confirmedUserId,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var invitedUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Invited,
|
||||
UserId = null,
|
||||
Email = "invited@example.com"
|
||||
};
|
||||
|
||||
var confirmedUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
UserId = confirmedUserId,
|
||||
Email = "confirmed@example.com"
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([invitedUser, confirmedUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.GetManyByManyUsersAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Count() == 1 && ids.First() == confirmedUserId));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_RevokedUsersIncluded_InComplianceCheck(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
|
||||
@@ -38,7 +38,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.DidNotReceive()
|
||||
.UpsertDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
|
||||
.CreateDefaultCollectionsBulkAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -60,7 +60,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.DidNotReceive()
|
||||
.UpsertDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
|
||||
.CreateDefaultCollectionsBulkAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -86,7 +86,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
// Assert
|
||||
await collectionRepository
|
||||
.DidNotReceive()
|
||||
.UpsertDefaultCollectionsAsync(
|
||||
.CreateDefaultCollectionsBulkAsync(
|
||||
Arg.Any<Guid>(),
|
||||
Arg.Any<IEnumerable<Guid>>(),
|
||||
Arg.Any<string>());
|
||||
@@ -172,10 +172,10 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
// Act
|
||||
await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
|
||||
|
||||
// Assert
|
||||
// Assert - Should call with all user IDs (repository does internal filtering)
|
||||
await collectionRepository
|
||||
.Received(1)
|
||||
.UpsertDefaultCollectionsAsync(
|
||||
.CreateDefaultCollectionsBulkAsync(
|
||||
policyUpdate.OrganizationId,
|
||||
Arg.Is<IEnumerable<Guid>>(ids => ids.Count() == 3),
|
||||
_defaultUserCollectionName);
|
||||
@@ -210,7 +210,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.DidNotReceive()
|
||||
.UpsertDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
|
||||
.CreateDefaultCollectionsBulkAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
private static IPolicyRepository ArrangePolicyRepository(IEnumerable<OrganizationPolicyDetails> policyDetails)
|
||||
@@ -251,7 +251,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpsertDefaultCollectionsAsync(default, default, default);
|
||||
.CreateDefaultCollectionsBulkAsync(default, default, default);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -273,7 +273,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpsertDefaultCollectionsAsync(default, default, default);
|
||||
.CreateDefaultCollectionsBulkAsync(default, default, default);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -299,7 +299,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
// Assert
|
||||
await collectionRepository
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpsertDefaultCollectionsAsync(
|
||||
.CreateDefaultCollectionsBulkAsync(
|
||||
default,
|
||||
default,
|
||||
default);
|
||||
@@ -336,10 +336,10 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
// Act
|
||||
await sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
|
||||
|
||||
// Assert
|
||||
// Assert - Should call with all user IDs (repository does internal filtering)
|
||||
await collectionRepository
|
||||
.Received(1)
|
||||
.UpsertDefaultCollectionsAsync(
|
||||
.CreateDefaultCollectionsBulkAsync(
|
||||
policyUpdate.OrganizationId,
|
||||
Arg.Is<IEnumerable<Guid>>(ids => ids.Count() == 3),
|
||||
_defaultUserCollectionName);
|
||||
@@ -367,6 +367,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpsertDefaultCollectionsAsync(default, default, default);
|
||||
.CreateDefaultCollectionsBulkAsync(default, default, default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using Bit.Core.Auth.UserFeatures.EmergencyAccess.Mail;
|
||||
using Bit.Core.Models.Mail;
|
||||
using Bit.Core.Platform.Mail.Delivery;
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
|
||||
|
||||
namespace Bit.Core.Test.Auth.UserFeatures.EmergencyAccess;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class EmergencyAccessMailTests
|
||||
{
|
||||
// Constant values for all Emergency Access emails
|
||||
private const string _emergencyAccessHelpUrl = "https://bitwarden.com/help/emergency-access/";
|
||||
private const string _emergencyAccessMailSubject = "Emergency contacts removed";
|
||||
|
||||
/// <summary>
|
||||
/// Documents how to construct and send the emergency access removal email.
|
||||
/// 1. Inject IMailer into their command/service
|
||||
/// 2. Construct EmergencyAccessRemoveGranteesMail as shown below
|
||||
/// 3. Call mailer.SendEmail(mail)
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task SendEmergencyAccessRemoveGranteesEmail_SingleGrantee_Success(
|
||||
string grantorEmail,
|
||||
string granteeName)
|
||||
{
|
||||
// Arrange
|
||||
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
|
||||
var globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
var deliveryService = Substitute.For<IMailDeliveryService>();
|
||||
var mailer = new Mailer(
|
||||
new HandlebarMailRenderer(logger, globalSettings),
|
||||
deliveryService);
|
||||
|
||||
var mail = new EmergencyAccessRemoveGranteesMail
|
||||
{
|
||||
ToEmails = [grantorEmail],
|
||||
View = new EmergencyAccessRemoveGranteesMailView
|
||||
{
|
||||
RemovedGranteeNames = [granteeName]
|
||||
}
|
||||
};
|
||||
|
||||
MailMessage sentMessage = null;
|
||||
await deliveryService.SendEmailAsync(Arg.Do<MailMessage>(message =>
|
||||
sentMessage = message
|
||||
));
|
||||
|
||||
// Act
|
||||
await mailer.SendEmail(mail);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(sentMessage);
|
||||
Assert.Contains(grantorEmail, sentMessage.ToEmails);
|
||||
|
||||
// Verify the content contains the grantee name
|
||||
Assert.Contains(granteeName, sentMessage.TextContent);
|
||||
Assert.Contains(granteeName, sentMessage.HtmlContent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Documents handling multiple removed grantees in a single email.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task SendEmergencyAccessRemoveGranteesEmail_MultipleGrantees_RendersAllNames(
|
||||
string grantorEmail)
|
||||
{
|
||||
// Arrange
|
||||
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
|
||||
var globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
var deliveryService = Substitute.For<IMailDeliveryService>();
|
||||
var mailer = new Mailer(
|
||||
new HandlebarMailRenderer(logger, globalSettings),
|
||||
deliveryService);
|
||||
|
||||
var granteeNames = new[] { "Alice", "Bob", "Carol" };
|
||||
|
||||
var mail = new EmergencyAccessRemoveGranteesMail
|
||||
{
|
||||
ToEmails = [grantorEmail],
|
||||
View = new EmergencyAccessRemoveGranteesMailView
|
||||
{
|
||||
RemovedGranteeNames = granteeNames
|
||||
}
|
||||
};
|
||||
|
||||
MailMessage sentMessage = null;
|
||||
await deliveryService.SendEmailAsync(Arg.Do<MailMessage>(message =>
|
||||
sentMessage = message
|
||||
));
|
||||
|
||||
// Act
|
||||
await mailer.SendEmail(mail);
|
||||
|
||||
// Assert - All grantee names should appear in the email
|
||||
Assert.NotNull(sentMessage);
|
||||
foreach (var granteeName in granteeNames)
|
||||
{
|
||||
Assert.Contains(granteeName, sentMessage.TextContent);
|
||||
Assert.Contains(granteeName, sentMessage.HtmlContent);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the required GranteeNames for the email view model.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public void EmergencyAccessRemoveGranteesMailView_GranteeNames_AreRequired(
|
||||
string grantorEmail)
|
||||
{
|
||||
// Arrange - Shows the minimum required to construct the email
|
||||
var mail = new EmergencyAccessRemoveGranteesMail
|
||||
{
|
||||
ToEmails = [grantorEmail], // Required: who to send to
|
||||
View = new EmergencyAccessRemoveGranteesMailView
|
||||
{
|
||||
// Required: at least one removed grantee name
|
||||
RemovedGranteeNames = ["Example Grantee"]
|
||||
}
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(mail);
|
||||
Assert.NotNull(mail.View);
|
||||
Assert.NotEmpty(mail.View.RemovedGranteeNames);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensure consistency with help pages link and email subject.
|
||||
/// </summary>
|
||||
/// <param name="grantorEmail"></param>
|
||||
/// <param name="granteeName"></param>
|
||||
[Theory, BitAutoData]
|
||||
public void EmergencyAccessRemoveGranteesMailView_SubjectAndHelpLink_MatchesExpectedValues(string grantorEmail, string granteeName)
|
||||
{
|
||||
// Arrange
|
||||
var mail = new EmergencyAccessRemoveGranteesMail
|
||||
{
|
||||
ToEmails = [grantorEmail],
|
||||
View = new EmergencyAccessRemoveGranteesMailView { RemovedGranteeNames = [granteeName] }
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(mail);
|
||||
Assert.NotNull(mail.View);
|
||||
Assert.Equal(_emergencyAccessMailSubject, mail.Subject);
|
||||
Assert.Equal(_emergencyAccessHelpUrl, mail.View.EmergencyAccessHelpPageUrl);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Auth.UserFeatures.EmergencyAccess;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@@ -17,7 +16,7 @@ using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Auth.Services;
|
||||
namespace Bit.Core.Test.Auth.UserFeatures.EmergencyAccess;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class EmergencyAccessServiceTests
|
||||
@@ -68,13 +67,13 @@ public class EmergencyAccessServiceTests
|
||||
Assert.Equal(EmergencyAccessStatusType.Invited, result.Status);
|
||||
await sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync(Arg.Any<EmergencyAccess>());
|
||||
.CreateAsync(Arg.Any<Core.Auth.Entities.EmergencyAccess>());
|
||||
sutProvider.GetDependency<IDataProtectorTokenFactory<EmergencyAccessInviteTokenable>>()
|
||||
.Received(1)
|
||||
.Protect(Arg.Any<EmergencyAccessInviteTokenable>());
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
.SendEmergencyAccessInviteEmailAsync(Arg.Any<EmergencyAccess>(), Arg.Any<string>(), Arg.Any<string>());
|
||||
.SendEmergencyAccessInviteEmailAsync(Arg.Any<Core.Auth.Entities.EmergencyAccess>(), Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -98,7 +97,7 @@ public class EmergencyAccessServiceTests
|
||||
User invitingUser,
|
||||
Guid emergencyAccessId)
|
||||
{
|
||||
EmergencyAccess emergencyAccess = null;
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess = null;
|
||||
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
@@ -119,7 +118,7 @@ public class EmergencyAccessServiceTests
|
||||
User invitingUser,
|
||||
Guid emergencyAccessId)
|
||||
{
|
||||
var emergencyAccess = new EmergencyAccess
|
||||
var emergencyAccess = new Core.Auth.Entities.EmergencyAccess
|
||||
{
|
||||
Status = EmergencyAccessStatusType.Invited,
|
||||
GrantorId = Guid.NewGuid(),
|
||||
@@ -148,7 +147,7 @@ public class EmergencyAccessServiceTests
|
||||
User invitingUser,
|
||||
Guid emergencyAccessId)
|
||||
{
|
||||
var emergencyAccess = new EmergencyAccess
|
||||
var emergencyAccess = new Core.Auth.Entities.EmergencyAccess
|
||||
{
|
||||
Status = statusType,
|
||||
GrantorId = invitingUser.Id,
|
||||
@@ -172,7 +171,7 @@ public class EmergencyAccessServiceTests
|
||||
User invitingUser,
|
||||
Guid emergencyAccessId)
|
||||
{
|
||||
var emergencyAccess = new EmergencyAccess
|
||||
var emergencyAccess = new Core.Auth.Entities.EmergencyAccess
|
||||
{
|
||||
Status = EmergencyAccessStatusType.Invited,
|
||||
GrantorId = invitingUser.Id,
|
||||
@@ -194,7 +193,7 @@ public class EmergencyAccessServiceTests
|
||||
public async Task AcceptUserAsync_EmergencyAccessNull_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider, User acceptingUser, string token)
|
||||
{
|
||||
EmergencyAccess emergencyAccess = null;
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess = null;
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns(emergencyAccess);
|
||||
@@ -209,7 +208,7 @@ public class EmergencyAccessServiceTests
|
||||
public async Task AcceptUserAsync_CannotUnprotectToken_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
User acceptingUser,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
string token)
|
||||
{
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
@@ -230,8 +229,8 @@ public class EmergencyAccessServiceTests
|
||||
public async Task AcceptUserAsync_TokenDataInvalid_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
User acceptingUser,
|
||||
EmergencyAccess emergencyAccess,
|
||||
EmergencyAccess wrongEmergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess wrongEmergencyAccess,
|
||||
string token)
|
||||
{
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
@@ -257,7 +256,7 @@ public class EmergencyAccessServiceTests
|
||||
public async Task AcceptUserAsync_AcceptedStatus_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
User acceptingUser,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
string token)
|
||||
{
|
||||
emergencyAccess.Status = EmergencyAccessStatusType.Accepted;
|
||||
@@ -284,7 +283,7 @@ public class EmergencyAccessServiceTests
|
||||
public async Task AcceptUserAsync_NotInvitedStatus_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
User acceptingUser,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
string token)
|
||||
{
|
||||
emergencyAccess.Status = EmergencyAccessStatusType.Confirmed;
|
||||
@@ -311,7 +310,7 @@ public class EmergencyAccessServiceTests
|
||||
public async Task AcceptUserAsync_EmergencyAccessEmailDoesNotMatch_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
User acceptingUser,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
string token)
|
||||
{
|
||||
emergencyAccess.Status = EmergencyAccessStatusType.Invited;
|
||||
@@ -339,7 +338,7 @@ public class EmergencyAccessServiceTests
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
User acceptingUser,
|
||||
User invitingUser,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
string token)
|
||||
{
|
||||
emergencyAccess.Status = EmergencyAccessStatusType.Invited;
|
||||
@@ -364,7 +363,7 @@ public class EmergencyAccessServiceTests
|
||||
|
||||
await sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.Received(1)
|
||||
.ReplaceAsync(Arg.Is<EmergencyAccess>(x => x.Status == EmergencyAccessStatusType.Accepted));
|
||||
.ReplaceAsync(Arg.Is<Core.Auth.Entities.EmergencyAccess>(x => x.Status == EmergencyAccessStatusType.Accepted));
|
||||
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
@@ -375,11 +374,11 @@ public class EmergencyAccessServiceTests
|
||||
public async Task DeleteAsync_EmergencyAccessNull_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
User invitingUser,
|
||||
EmergencyAccess emergencyAccess)
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess)
|
||||
{
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns((EmergencyAccess)null);
|
||||
.Returns((Core.Auth.Entities.EmergencyAccess)null);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.DeleteAsync(emergencyAccess.Id, invitingUser.Id));
|
||||
@@ -391,7 +390,7 @@ public class EmergencyAccessServiceTests
|
||||
public async Task DeleteAsync_EmergencyAccessGrantorIdNotEqual_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
User invitingUser,
|
||||
EmergencyAccess emergencyAccess)
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess)
|
||||
{
|
||||
emergencyAccess.GrantorId = Guid.NewGuid();
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
@@ -408,7 +407,7 @@ public class EmergencyAccessServiceTests
|
||||
public async Task DeleteAsync_EmergencyAccessGranteeIdNotEqual_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
User invitingUser,
|
||||
EmergencyAccess emergencyAccess)
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess)
|
||||
{
|
||||
emergencyAccess.GranteeId = Guid.NewGuid();
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
@@ -425,7 +424,7 @@ public class EmergencyAccessServiceTests
|
||||
public async Task DeleteAsync_EmergencyAccessIsDeleted_Success(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
User user,
|
||||
EmergencyAccess emergencyAccess)
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess)
|
||||
{
|
||||
emergencyAccess.GranteeId = user.Id;
|
||||
emergencyAccess.GrantorId = user.Id;
|
||||
@@ -443,7 +442,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUserAsync_EmergencyAccessNull_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
string key,
|
||||
User grantorUser)
|
||||
{
|
||||
@@ -451,7 +450,7 @@ public class EmergencyAccessServiceTests
|
||||
emergencyAccess.Status = EmergencyAccessStatusType.RecoveryInitiated;
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns((EmergencyAccess)null);
|
||||
.Returns((Core.Auth.Entities.EmergencyAccess)null);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.ConfirmUserAsync(emergencyAccess.Id, key, grantorUser.Id));
|
||||
@@ -463,7 +462,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUserAsync_EmergencyAccessStatusIsNotAccepted_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
string key,
|
||||
User grantorUser)
|
||||
{
|
||||
@@ -484,7 +483,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUserAsync_EmergencyAccessGrantorIdNotEqualToConfirmingUserId_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
string key,
|
||||
User grantorUser)
|
||||
{
|
||||
@@ -505,7 +504,7 @@ public class EmergencyAccessServiceTests
|
||||
SutProvider<EmergencyAccessService> sutProvider, User confirmingUser, string key)
|
||||
{
|
||||
confirmingUser.UsesKeyConnector = true;
|
||||
var emergencyAccess = new EmergencyAccess
|
||||
var emergencyAccess = new Core.Auth.Entities.EmergencyAccess
|
||||
{
|
||||
Status = EmergencyAccessStatusType.Accepted,
|
||||
GrantorId = confirmingUser.Id,
|
||||
@@ -530,7 +529,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUserAsync_ConfirmsAndReplacesEmergencyAccess_Success(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
string key,
|
||||
User grantorUser,
|
||||
User granteeUser)
|
||||
@@ -553,7 +552,7 @@ public class EmergencyAccessServiceTests
|
||||
|
||||
await sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.Received(1)
|
||||
.ReplaceAsync(Arg.Is<EmergencyAccess>(x => x.Status == EmergencyAccessStatusType.Confirmed));
|
||||
.ReplaceAsync(Arg.Is<Core.Auth.Entities.EmergencyAccess>(x => x.Status == EmergencyAccessStatusType.Confirmed));
|
||||
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
@@ -564,7 +563,7 @@ public class EmergencyAccessServiceTests
|
||||
public async Task SaveAsync_PremiumCannotUpdate_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider, User savingUser)
|
||||
{
|
||||
var emergencyAccess = new EmergencyAccess
|
||||
var emergencyAccess = new Core.Auth.Entities.EmergencyAccess
|
||||
{
|
||||
Type = EmergencyAccessType.Takeover,
|
||||
GrantorId = savingUser.Id,
|
||||
@@ -586,7 +585,7 @@ public class EmergencyAccessServiceTests
|
||||
SutProvider<EmergencyAccessService> sutProvider, User savingUser)
|
||||
{
|
||||
savingUser.Premium = true;
|
||||
var emergencyAccess = new EmergencyAccess
|
||||
var emergencyAccess = new Core.Auth.Entities.EmergencyAccess
|
||||
{
|
||||
Type = EmergencyAccessType.Takeover,
|
||||
GrantorId = new Guid(),
|
||||
@@ -611,7 +610,7 @@ public class EmergencyAccessServiceTests
|
||||
SutProvider<EmergencyAccessService> sutProvider, User grantorUser)
|
||||
{
|
||||
grantorUser.UsesKeyConnector = true;
|
||||
var emergencyAccess = new EmergencyAccess
|
||||
var emergencyAccess = new Core.Auth.Entities.EmergencyAccess
|
||||
{
|
||||
Type = EmergencyAccessType.Takeover,
|
||||
GrantorId = grantorUser.Id,
|
||||
@@ -633,7 +632,7 @@ public class EmergencyAccessServiceTests
|
||||
SutProvider<EmergencyAccessService> sutProvider, User grantorUser)
|
||||
{
|
||||
grantorUser.UsesKeyConnector = true;
|
||||
var emergencyAccess = new EmergencyAccess
|
||||
var emergencyAccess = new Core.Auth.Entities.EmergencyAccess
|
||||
{
|
||||
Type = EmergencyAccessType.View,
|
||||
GrantorId = grantorUser.Id,
|
||||
@@ -655,7 +654,7 @@ public class EmergencyAccessServiceTests
|
||||
SutProvider<EmergencyAccessService> sutProvider, User grantorUser)
|
||||
{
|
||||
grantorUser.UsesKeyConnector = false;
|
||||
var emergencyAccess = new EmergencyAccess
|
||||
var emergencyAccess = new Core.Auth.Entities.EmergencyAccess
|
||||
{
|
||||
Type = EmergencyAccessType.Takeover,
|
||||
GrantorId = grantorUser.Id,
|
||||
@@ -678,7 +677,7 @@ public class EmergencyAccessServiceTests
|
||||
{
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns((EmergencyAccess)null);
|
||||
.Returns((Core.Auth.Entities.EmergencyAccess)null);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.InitiateAsync(new Guid(), initiatingUser));
|
||||
@@ -692,7 +691,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task InitiateAsync_EmergencyAccessGranteeIdNotEqual_ThrowBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User initiatingUser)
|
||||
{
|
||||
emergencyAccess.GranteeId = new Guid();
|
||||
@@ -712,7 +711,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task InitiateAsync_EmergencyAccessStatusIsNotConfirmed_ThrowBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User initiatingUser)
|
||||
{
|
||||
emergencyAccess.GranteeId = initiatingUser.Id;
|
||||
@@ -735,7 +734,7 @@ public class EmergencyAccessServiceTests
|
||||
SutProvider<EmergencyAccessService> sutProvider, User initiatingUser, User grantor)
|
||||
{
|
||||
grantor.UsesKeyConnector = true;
|
||||
var emergencyAccess = new EmergencyAccess
|
||||
var emergencyAccess = new Core.Auth.Entities.EmergencyAccess
|
||||
{
|
||||
Status = EmergencyAccessStatusType.Confirmed,
|
||||
GranteeId = initiatingUser.Id,
|
||||
@@ -764,7 +763,7 @@ public class EmergencyAccessServiceTests
|
||||
SutProvider<EmergencyAccessService> sutProvider, User initiatingUser, User grantor)
|
||||
{
|
||||
grantor.UsesKeyConnector = true;
|
||||
var emergencyAccess = new EmergencyAccess
|
||||
var emergencyAccess = new Core.Auth.Entities.EmergencyAccess
|
||||
{
|
||||
Status = EmergencyAccessStatusType.Confirmed,
|
||||
GranteeId = initiatingUser.Id,
|
||||
@@ -783,14 +782,14 @@ public class EmergencyAccessServiceTests
|
||||
|
||||
await sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.Received(1)
|
||||
.ReplaceAsync(Arg.Is<EmergencyAccess>(x => x.Status == EmergencyAccessStatusType.RecoveryInitiated));
|
||||
.ReplaceAsync(Arg.Is<Core.Auth.Entities.EmergencyAccess>(x => x.Status == EmergencyAccessStatusType.RecoveryInitiated));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task InitiateAsync_RequestIsCorrect_Success(
|
||||
SutProvider<EmergencyAccessService> sutProvider, User initiatingUser, User grantor)
|
||||
{
|
||||
var emergencyAccess = new EmergencyAccess
|
||||
var emergencyAccess = new Core.Auth.Entities.EmergencyAccess
|
||||
{
|
||||
Status = EmergencyAccessStatusType.Confirmed,
|
||||
GranteeId = initiatingUser.Id,
|
||||
@@ -809,7 +808,7 @@ public class EmergencyAccessServiceTests
|
||||
|
||||
await sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.Received(1)
|
||||
.ReplaceAsync(Arg.Is<EmergencyAccess>(x => x.Status == EmergencyAccessStatusType.RecoveryInitiated));
|
||||
.ReplaceAsync(Arg.Is<Core.Auth.Entities.EmergencyAccess>(x => x.Status == EmergencyAccessStatusType.RecoveryInitiated));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -818,7 +817,7 @@ public class EmergencyAccessServiceTests
|
||||
{
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns((EmergencyAccess)null);
|
||||
.Returns((Core.Auth.Entities.EmergencyAccess)null);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.ApproveAsync(new Guid(), null));
|
||||
@@ -829,7 +828,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task ApproveAsync_EmergencyAccessGrantorIdNotEquatToApproving_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User grantorUser)
|
||||
{
|
||||
emergencyAccess.Status = EmergencyAccessStatusType.RecoveryInitiated;
|
||||
@@ -851,7 +850,7 @@ public class EmergencyAccessServiceTests
|
||||
public async Task ApproveAsync_EmergencyAccessStatusNotRecoveryInitiated_ThrowsBadRequest(
|
||||
EmergencyAccessStatusType statusType,
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User grantorUser)
|
||||
{
|
||||
emergencyAccess.GrantorId = grantorUser.Id;
|
||||
@@ -869,7 +868,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task ApproveAsync_Success(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User grantorUser,
|
||||
User granteeUser)
|
||||
{
|
||||
@@ -885,20 +884,20 @@ public class EmergencyAccessServiceTests
|
||||
await sutProvider.Sut.ApproveAsync(emergencyAccess.Id, grantorUser);
|
||||
await sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.Received(1)
|
||||
.ReplaceAsync(Arg.Is<EmergencyAccess>(x => x.Status == EmergencyAccessStatusType.RecoveryApproved));
|
||||
.ReplaceAsync(Arg.Is<Core.Auth.Entities.EmergencyAccess>(x => x.Status == EmergencyAccessStatusType.RecoveryApproved));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RejectAsync_EmergencyAccessIdNull_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User GrantorUser)
|
||||
{
|
||||
emergencyAccess.GrantorId = GrantorUser.Id;
|
||||
emergencyAccess.Status = EmergencyAccessStatusType.Accepted;
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns((EmergencyAccess)null);
|
||||
.Returns((Core.Auth.Entities.EmergencyAccess)null);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.RejectAsync(emergencyAccess.Id, GrantorUser));
|
||||
@@ -909,7 +908,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task RejectAsync_EmergencyAccessGrantorIdNotEqualToRequestUser_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User GrantorUser)
|
||||
{
|
||||
emergencyAccess.Status = EmergencyAccessStatusType.Accepted;
|
||||
@@ -930,7 +929,7 @@ public class EmergencyAccessServiceTests
|
||||
public async Task RejectAsync_EmergencyAccessStatusNotValid_ThrowsBadRequest(
|
||||
EmergencyAccessStatusType statusType,
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User GrantorUser)
|
||||
{
|
||||
emergencyAccess.GrantorId = GrantorUser.Id;
|
||||
@@ -951,7 +950,7 @@ public class EmergencyAccessServiceTests
|
||||
public async Task RejectAsync_Success(
|
||||
EmergencyAccessStatusType statusType,
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User GrantorUser,
|
||||
User GranteeUser)
|
||||
{
|
||||
@@ -968,7 +967,7 @@ public class EmergencyAccessServiceTests
|
||||
|
||||
await sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.Received(1)
|
||||
.ReplaceAsync(Arg.Is<EmergencyAccess>(x => x.Status == EmergencyAccessStatusType.Confirmed));
|
||||
.ReplaceAsync(Arg.Is<Core.Auth.Entities.EmergencyAccess>(x => x.Status == EmergencyAccessStatusType.Confirmed));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -977,7 +976,7 @@ public class EmergencyAccessServiceTests
|
||||
{
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns((EmergencyAccess)null);
|
||||
.Returns((Core.Auth.Entities.EmergencyAccess)null);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.GetPoliciesAsync(default, default));
|
||||
@@ -992,7 +991,7 @@ public class EmergencyAccessServiceTests
|
||||
public async Task GetPoliciesAsync_RequestNotValidStatusType_ThrowsBadRequest(
|
||||
EmergencyAccessStatusType statusType,
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User granteeUser)
|
||||
{
|
||||
emergencyAccess.GranteeId = granteeUser.Id;
|
||||
@@ -1010,7 +1009,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetPoliciesAsync_RequestNotValidType_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User granteeUser)
|
||||
{
|
||||
emergencyAccess.GranteeId = granteeUser.Id;
|
||||
@@ -1032,7 +1031,7 @@ public class EmergencyAccessServiceTests
|
||||
public async Task GetPoliciesAsync_OrganizationUserTypeNotOwner_ReturnsNull(
|
||||
OrganizationUserType userType,
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User granteeUser,
|
||||
User grantorUser,
|
||||
OrganizationUser grantorOrganizationUser)
|
||||
@@ -1062,7 +1061,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetPoliciesAsync_OrganizationUserEmpty_ReturnsNull(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User granteeUser,
|
||||
User grantorUser)
|
||||
{
|
||||
@@ -1090,7 +1089,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetPoliciesAsync_ReturnsNotNull(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User granteeUser,
|
||||
User grantorUser,
|
||||
OrganizationUser grantorOrganizationUser)
|
||||
@@ -1127,7 +1126,7 @@ public class EmergencyAccessServiceTests
|
||||
{
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns((EmergencyAccess)null);
|
||||
.Returns((Core.Auth.Entities.EmergencyAccess)null);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.TakeoverAsync(default, default));
|
||||
@@ -1138,7 +1137,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task TakeoverAsync_RequestNotValid_GranteeNotEqualToRequestingUser_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User granteeUser)
|
||||
{
|
||||
emergencyAccess.Status = EmergencyAccessStatusType.RecoveryApproved;
|
||||
@@ -1161,7 +1160,7 @@ public class EmergencyAccessServiceTests
|
||||
public async Task TakeoverAsync_RequestNotValid_StatusType_ThrowsBadRequest(
|
||||
EmergencyAccessStatusType statusType,
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User granteeUser)
|
||||
{
|
||||
emergencyAccess.GranteeId = granteeUser.Id;
|
||||
@@ -1180,7 +1179,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task TakeoverAsync_RequestNotValid_TypeIsView_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User granteeUser)
|
||||
{
|
||||
emergencyAccess.GranteeId = granteeUser.Id;
|
||||
@@ -1203,7 +1202,7 @@ public class EmergencyAccessServiceTests
|
||||
User grantor)
|
||||
{
|
||||
grantor.UsesKeyConnector = true;
|
||||
var emergencyAccess = new EmergencyAccess
|
||||
var emergencyAccess = new Core.Auth.Entities.EmergencyAccess
|
||||
{
|
||||
GrantorId = grantor.Id,
|
||||
GranteeId = granteeUser.Id,
|
||||
@@ -1232,7 +1231,7 @@ public class EmergencyAccessServiceTests
|
||||
User grantor)
|
||||
{
|
||||
grantor.UsesKeyConnector = false;
|
||||
var emergencyAccess = new EmergencyAccess
|
||||
var emergencyAccess = new Core.Auth.Entities.EmergencyAccess
|
||||
{
|
||||
GrantorId = grantor.Id,
|
||||
GranteeId = granteeUser.Id,
|
||||
@@ -1260,7 +1259,7 @@ public class EmergencyAccessServiceTests
|
||||
{
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns((EmergencyAccess)null);
|
||||
.Returns((Core.Auth.Entities.EmergencyAccess)null);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.PasswordAsync(default, default, default, default));
|
||||
@@ -1271,7 +1270,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task PasswordAsync_RequestNotValid_GranteeNotEqualToRequestingUser_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User granteeUser)
|
||||
{
|
||||
emergencyAccess.Status = EmergencyAccessStatusType.RecoveryApproved;
|
||||
@@ -1294,7 +1293,7 @@ public class EmergencyAccessServiceTests
|
||||
public async Task PasswordAsync_RequestNotValid_StatusType_ThrowsBadRequest(
|
||||
EmergencyAccessStatusType statusType,
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User granteeUser)
|
||||
{
|
||||
emergencyAccess.GranteeId = granteeUser.Id;
|
||||
@@ -1313,7 +1312,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task PasswordAsync_RequestNotValid_TypeIsView_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User granteeUser)
|
||||
{
|
||||
emergencyAccess.GranteeId = granteeUser.Id;
|
||||
@@ -1332,7 +1331,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task PasswordAsync_NonOrgUser_Success(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User granteeUser,
|
||||
User grantorUser,
|
||||
string key,
|
||||
@@ -1367,7 +1366,7 @@ public class EmergencyAccessServiceTests
|
||||
public async Task PasswordAsync_OrgUser_NotOrganizationOwner_RemovedFromOrganization_Success(
|
||||
OrganizationUserType userType,
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User granteeUser,
|
||||
User grantorUser,
|
||||
OrganizationUser organizationUser,
|
||||
@@ -1408,7 +1407,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task PasswordAsync_OrgUser_IsOrganizationOwner_NotRemovedFromOrganization_Success(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User granteeUser,
|
||||
User grantorUser,
|
||||
OrganizationUser organizationUser,
|
||||
@@ -1459,7 +1458,7 @@ public class EmergencyAccessServiceTests
|
||||
Enabled = true
|
||||
}
|
||||
});
|
||||
var emergencyAccess = new EmergencyAccess
|
||||
var emergencyAccess = new Core.Auth.Entities.EmergencyAccess
|
||||
{
|
||||
GrantorId = grantor.Id,
|
||||
GranteeId = requestingUser.Id,
|
||||
@@ -1484,7 +1483,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task ViewAsync_EmergencyAccessTypeNotView_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User granteeUser)
|
||||
{
|
||||
emergencyAccess.GranteeId = granteeUser.Id;
|
||||
@@ -1500,7 +1499,7 @@ public class EmergencyAccessServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetAttachmentDownloadAsync_EmergencyAccessTypeNotView_ThrowsBadRequest(
|
||||
SutProvider<EmergencyAccessService> sutProvider,
|
||||
EmergencyAccess emergencyAccess,
|
||||
Core.Auth.Entities.EmergencyAccess emergencyAccess,
|
||||
User granteeUser)
|
||||
{
|
||||
emergencyAccess.GranteeId = granteeUser.Id;
|
||||
@@ -2,7 +2,6 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
@@ -23,6 +22,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using EmergencyAccessEntity = Bit.Core.Auth.Entities.EmergencyAccess;
|
||||
|
||||
namespace Bit.Core.Test.Auth.UserFeatures.Registration;
|
||||
|
||||
@@ -726,7 +726,7 @@ public class RegisterUserCommandTests
|
||||
[BitAutoData]
|
||||
public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_Succeeds(
|
||||
SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash,
|
||||
EmergencyAccess emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId)
|
||||
EmergencyAccessEntity emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId)
|
||||
{
|
||||
// Arrange
|
||||
user.Email = $"test+{Guid.NewGuid()}@example.com";
|
||||
@@ -767,7 +767,7 @@ public class RegisterUserCommandTests
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_InvalidToken_ThrowsBadRequestException(SutProvider<RegisterUserCommand> sutProvider, User user,
|
||||
string masterPasswordHash, EmergencyAccess emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId)
|
||||
string masterPasswordHash, EmergencyAccessEntity emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId)
|
||||
{
|
||||
// Arrange
|
||||
user.Email = $"test+{Guid.NewGuid()}@example.com";
|
||||
@@ -1112,7 +1112,7 @@ public class RegisterUserCommandTests
|
||||
[BitAutoData]
|
||||
public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_BlockedDomain_ThrowsBadRequestException(
|
||||
SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash,
|
||||
EmergencyAccess emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId)
|
||||
EmergencyAccessEntity emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId)
|
||||
{
|
||||
// Arrange
|
||||
user.Email = "user@blocked-domain.com";
|
||||
|
||||
68
test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs
Normal file
68
test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System.Reflection;
|
||||
using Bit.Core.Billing.Licenses;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Licenses;
|
||||
|
||||
public class LicenseConstantsTests
|
||||
{
|
||||
[Fact]
|
||||
public void OrganizationLicenseConstants_HasConstantForEveryLicenseProperty()
|
||||
{
|
||||
// This test ensures that when a new property is added to OrganizationLicense,
|
||||
// a corresponding constant is added to OrganizationLicenseConstants.
|
||||
// This is the first step in the license synchronization pipeline:
|
||||
// Property → Constant → Claim → Extraction → Application
|
||||
|
||||
// 1. Get all public properties from OrganizationLicense
|
||||
var licenseProperties = typeof(OrganizationLicense)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Select(p => p.Name)
|
||||
.ToHashSet();
|
||||
|
||||
// 2. Get all constants from OrganizationLicenseConstants
|
||||
var constants = typeof(OrganizationLicenseConstants)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral && !f.IsInitOnly)
|
||||
.Select(f => f.GetValue(null) as string)
|
||||
.ToHashSet();
|
||||
|
||||
// 3. Define properties that don't need constants (internal/computed/non-claims properties)
|
||||
var excludedProperties = new HashSet<string>
|
||||
{
|
||||
"SignatureBytes", // Computed from Signature property
|
||||
"ValidLicenseVersion", // Internal property, not serialized
|
||||
"CurrentLicenseFileVersion", // Constant field, not an instance property
|
||||
"Hash", // Signature-related, not in claims system
|
||||
"Signature", // Signature-related, not in claims system
|
||||
"Token", // The JWT itself, not a claim within the token
|
||||
"Version" // Not in claims system (only in deprecated property-based licenses)
|
||||
};
|
||||
|
||||
// 4. Find license properties without corresponding constants
|
||||
var propertiesWithoutConstants = licenseProperties
|
||||
.Except(constants)
|
||||
.Except(excludedProperties)
|
||||
.OrderBy(p => p)
|
||||
.ToList();
|
||||
|
||||
// 5. Build error message with guidance
|
||||
var errorMessage = "";
|
||||
if (propertiesWithoutConstants.Any())
|
||||
{
|
||||
errorMessage = $"The following OrganizationLicense properties don't have constants in OrganizationLicenseConstants:\n";
|
||||
errorMessage += string.Join("\n", propertiesWithoutConstants.Select(p => $" - {p}"));
|
||||
errorMessage += "\n\nPlease add the following constants to OrganizationLicenseConstants:\n";
|
||||
foreach (var prop in propertiesWithoutConstants)
|
||||
{
|
||||
errorMessage += $" public const string {prop} = nameof({prop});\n";
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Assert - if this fails, the error message guides the developer to add the constant
|
||||
Assert.True(
|
||||
!propertiesWithoutConstants.Any(),
|
||||
$"\n{errorMessage}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Reflection;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Licenses;
|
||||
using Bit.Core.Billing.Licenses.Models;
|
||||
using Bit.Core.Billing.Licenses.Services.Implementations;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Licenses.Services.Implementations;
|
||||
|
||||
public class OrganizationLicenseClaimsFactoryTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task GenerateClaims_CreatesClaimsForAllConstants(Organization organization)
|
||||
{
|
||||
// This test ensures that when a constant is added to OrganizationLicenseConstants,
|
||||
// it is also added to the OrganizationLicenseClaimsFactory to generate claims.
|
||||
// This is the second step in the license synchronization pipeline:
|
||||
// Property → Constant → Claim → Extraction → Application
|
||||
|
||||
// 1. Populate all nullable properties to ensure claims can be generated
|
||||
// The factory only adds claims for properties that have values
|
||||
organization.Name = "Test Organization";
|
||||
organization.BillingEmail = "billing@test.com";
|
||||
organization.BusinessName = "Test Business";
|
||||
organization.Plan = "Enterprise";
|
||||
organization.LicenseKey = "test-license-key";
|
||||
organization.Seats = 100;
|
||||
organization.MaxCollections = 50;
|
||||
organization.MaxStorageGb = 10;
|
||||
organization.SmSeats = 25;
|
||||
organization.SmServiceAccounts = 10;
|
||||
organization.ExpirationDate = DateTime.UtcNow.AddYears(1); // Ensure org is not expired
|
||||
|
||||
// Create a LicenseContext with a minimal SubscriptionInfo to trigger conditional claims
|
||||
// ExpirationWithoutGracePeriod is only generated for active, non-trial, annual subscriptions
|
||||
var licenseContext = new LicenseContext
|
||||
{
|
||||
InstallationId = Guid.NewGuid(),
|
||||
SubscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
Subscription = new SubscriptionInfo.BillingSubscription(null!)
|
||||
{
|
||||
TrialEndDate = DateTime.UtcNow.AddDays(-30), // Trial ended in the past
|
||||
PeriodStartDate = DateTime.UtcNow,
|
||||
PeriodEndDate = DateTime.UtcNow.AddDays(365), // Annual subscription (>180 days)
|
||||
Status = "active"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Generate claims
|
||||
var factory = new OrganizationLicenseClaimsFactory();
|
||||
var claims = await factory.GenerateClaims(organization, licenseContext);
|
||||
|
||||
// 3. Get all constants from OrganizationLicenseConstants
|
||||
var allConstants = typeof(OrganizationLicenseConstants)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral && !f.IsInitOnly)
|
||||
.Select(f => f.GetValue(null) as string)
|
||||
.ToHashSet();
|
||||
|
||||
// 4. Get claim types from generated claims
|
||||
var generatedClaimTypes = claims.Select(c => c.Type).ToHashSet();
|
||||
|
||||
// 5. Find constants that don't have corresponding claims
|
||||
var constantsWithoutClaims = allConstants
|
||||
.Except(generatedClaimTypes)
|
||||
.OrderBy(c => c)
|
||||
.ToList();
|
||||
|
||||
// 6. Build error message with guidance
|
||||
var errorMessage = "";
|
||||
if (constantsWithoutClaims.Any())
|
||||
{
|
||||
errorMessage = $"The following constants in OrganizationLicenseConstants are NOT generated as claims in OrganizationLicenseClaimsFactory:\n";
|
||||
errorMessage += string.Join("\n", constantsWithoutClaims.Select(c => $" - {c}"));
|
||||
errorMessage += "\n\nPlease add the following claims to OrganizationLicenseClaimsFactory.GenerateClaims():\n";
|
||||
foreach (var constant in constantsWithoutClaims)
|
||||
{
|
||||
errorMessage += $" new(nameof(OrganizationLicenseConstants.{constant}), entity.{constant}.ToString()),\n";
|
||||
}
|
||||
errorMessage += "\nNote: If the property is nullable, you may need to add it conditionally.";
|
||||
}
|
||||
|
||||
// 7. Assert - if this fails, the error message guides the developer to add claim generation
|
||||
Assert.True(
|
||||
!constantsWithoutClaims.Any(),
|
||||
$"\n{errorMessage}");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
using System.Security.Claims;
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using System.Text.RegularExpressions;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Licenses;
|
||||
using Bit.Core.Billing.Organizations.Commands;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
@@ -99,6 +104,320 @@ public class UpdateOrganizationLicenseCommandTests
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateLicenseAsync_WithClaimsPrincipal_ExtractsAllPropertiesFromClaims(
|
||||
SelfHostedOrganizationDetails selfHostedOrg,
|
||||
OrganizationLicense license,
|
||||
SutProvider<UpdateOrganizationLicenseCommand> sutProvider)
|
||||
{
|
||||
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
|
||||
globalSettings.LicenseDirectory = LicenseDirectory;
|
||||
globalSettings.SelfHosted = true;
|
||||
|
||||
// Setup license for CanUse validation
|
||||
license.Enabled = true;
|
||||
license.Issued = DateTime.Now.AddDays(-1);
|
||||
license.Expires = DateTime.Now.AddDays(1);
|
||||
license.Version = OrganizationLicense.CurrentLicenseFileVersion;
|
||||
license.InstallationId = globalSettings.Installation.Id;
|
||||
license.LicenseType = LicenseType.Organization;
|
||||
license.Token = "test-token"; // Indicates this is a claims-based license
|
||||
sutProvider.GetDependency<ILicensingService>().VerifyLicense(license).Returns(true);
|
||||
|
||||
// Create a ClaimsPrincipal with all organization license claims
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(OrganizationLicenseConstants.LicenseType, ((int)LicenseType.Organization).ToString()),
|
||||
new(OrganizationLicenseConstants.InstallationId, globalSettings.Installation.Id.ToString()),
|
||||
new(OrganizationLicenseConstants.Name, "Test Organization"),
|
||||
new(OrganizationLicenseConstants.BillingEmail, "billing@test.com"),
|
||||
new(OrganizationLicenseConstants.BusinessName, "Test Business"),
|
||||
new(OrganizationLicenseConstants.PlanType, ((int)PlanType.EnterpriseAnnually).ToString()),
|
||||
new(OrganizationLicenseConstants.Seats, "100"),
|
||||
new(OrganizationLicenseConstants.MaxCollections, "50"),
|
||||
new(OrganizationLicenseConstants.UsePolicies, "true"),
|
||||
new(OrganizationLicenseConstants.UseSso, "true"),
|
||||
new(OrganizationLicenseConstants.UseKeyConnector, "true"),
|
||||
new(OrganizationLicenseConstants.UseScim, "true"),
|
||||
new(OrganizationLicenseConstants.UseGroups, "true"),
|
||||
new(OrganizationLicenseConstants.UseDirectory, "true"),
|
||||
new(OrganizationLicenseConstants.UseEvents, "true"),
|
||||
new(OrganizationLicenseConstants.UseTotp, "true"),
|
||||
new(OrganizationLicenseConstants.Use2fa, "true"),
|
||||
new(OrganizationLicenseConstants.UseApi, "true"),
|
||||
new(OrganizationLicenseConstants.UseResetPassword, "true"),
|
||||
new(OrganizationLicenseConstants.Plan, "Enterprise"),
|
||||
new(OrganizationLicenseConstants.SelfHost, "true"),
|
||||
new(OrganizationLicenseConstants.UsersGetPremium, "true"),
|
||||
new(OrganizationLicenseConstants.UseCustomPermissions, "true"),
|
||||
new(OrganizationLicenseConstants.Enabled, "true"),
|
||||
new(OrganizationLicenseConstants.Expires, DateTime.Now.AddDays(1).ToString("O")),
|
||||
new(OrganizationLicenseConstants.LicenseKey, "test-license-key"),
|
||||
new(OrganizationLicenseConstants.UsePasswordManager, "true"),
|
||||
new(OrganizationLicenseConstants.UseSecretsManager, "true"),
|
||||
new(OrganizationLicenseConstants.SmSeats, "25"),
|
||||
new(OrganizationLicenseConstants.SmServiceAccounts, "10"),
|
||||
new(OrganizationLicenseConstants.UseRiskInsights, "true"),
|
||||
new(OrganizationLicenseConstants.UseOrganizationDomains, "true"),
|
||||
new(OrganizationLicenseConstants.UseAdminSponsoredFamilies, "true"),
|
||||
new(OrganizationLicenseConstants.UseAutomaticUserConfirmation, "true"),
|
||||
new(OrganizationLicenseConstants.UseDisableSmAdsForUsers, "true"),
|
||||
new(OrganizationLicenseConstants.UsePhishingBlocker, "true"),
|
||||
new(OrganizationLicenseConstants.MaxStorageGb, "5"),
|
||||
new(OrganizationLicenseConstants.Issued, DateTime.Now.AddDays(-1).ToString("O")),
|
||||
new(OrganizationLicenseConstants.Refresh, DateTime.Now.AddMonths(1).ToString("O")),
|
||||
new(OrganizationLicenseConstants.ExpirationWithoutGracePeriod, DateTime.Now.AddMonths(12).ToString("O")),
|
||||
new(OrganizationLicenseConstants.Trial, "false"),
|
||||
new(OrganizationLicenseConstants.LimitCollectionCreationDeletion, "true"),
|
||||
new(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems, "true")
|
||||
};
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims));
|
||||
|
||||
sutProvider.GetDependency<ILicensingService>()
|
||||
.GetClaimsPrincipalFromLicense(license)
|
||||
.Returns(claimsPrincipal);
|
||||
|
||||
// Setup selfHostedOrg for CanUseLicense validation
|
||||
selfHostedOrg.OccupiedSeatCount = 50; // Less than the 100 seats in the license
|
||||
selfHostedOrg.CollectionCount = 10; // Less than the 50 max collections in the license
|
||||
selfHostedOrg.GroupCount = 1;
|
||||
selfHostedOrg.UseGroups = true;
|
||||
selfHostedOrg.UsePolicies = true;
|
||||
selfHostedOrg.UseSso = true;
|
||||
selfHostedOrg.UseKeyConnector = true;
|
||||
selfHostedOrg.UseScim = true;
|
||||
selfHostedOrg.UseCustomPermissions = true;
|
||||
selfHostedOrg.UseResetPassword = true;
|
||||
|
||||
try
|
||||
{
|
||||
await sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null);
|
||||
|
||||
// Assertion: license file should be written to disk
|
||||
var filePath = Path.Combine(LicenseDirectory, "organization", $"{selfHostedOrg.Id}.json");
|
||||
await using var fs = File.OpenRead(filePath);
|
||||
var licenseFromFile = await JsonSerializer.DeserializeAsync<OrganizationLicense>(fs);
|
||||
|
||||
AssertHelper.AssertPropertyEqual(license, licenseFromFile, "SignatureBytes");
|
||||
|
||||
// Assertion: organization should be updated with ALL properties extracted from claims
|
||||
await sutProvider.GetDependency<IOrganizationService>()
|
||||
.Received(1)
|
||||
.ReplaceAndUpdateCacheAsync(Arg.Is<Organization>(org =>
|
||||
org.Name == "Test Organization" &&
|
||||
org.BillingEmail == "billing@test.com" &&
|
||||
org.BusinessName == "Test Business" &&
|
||||
org.PlanType == PlanType.EnterpriseAnnually &&
|
||||
org.Seats == 100 &&
|
||||
org.MaxCollections == 50 &&
|
||||
org.UsePolicies == true &&
|
||||
org.UseSso == true &&
|
||||
org.UseKeyConnector == true &&
|
||||
org.UseScim == true &&
|
||||
org.UseGroups == true &&
|
||||
org.UseDirectory == true &&
|
||||
org.UseEvents == true &&
|
||||
org.UseTotp == true &&
|
||||
org.Use2fa == true &&
|
||||
org.UseApi == true &&
|
||||
org.UseResetPassword == true &&
|
||||
org.Plan == "Enterprise" &&
|
||||
org.SelfHost == true &&
|
||||
org.UsersGetPremium == true &&
|
||||
org.UseCustomPermissions == true &&
|
||||
org.Enabled == true &&
|
||||
org.LicenseKey == "test-license-key" &&
|
||||
org.UsePasswordManager == true &&
|
||||
org.UseSecretsManager == true &&
|
||||
org.SmSeats == 25 &&
|
||||
org.SmServiceAccounts == 10 &&
|
||||
org.UseRiskInsights == true &&
|
||||
org.UseOrganizationDomains == true &&
|
||||
org.UseAdminSponsoredFamilies == true &&
|
||||
org.UseAutomaticUserConfirmation == true &&
|
||||
org.UseDisableSmAdsForUsers == true &&
|
||||
org.UsePhishingBlocker == true));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up temporary directory
|
||||
if (Directory.Exists(OrganizationLicenseDirectory.Value))
|
||||
{
|
||||
Directory.Delete(OrganizationLicenseDirectory.Value, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateLicenseAsync_WrongInstallationIdInClaims_ThrowsBadRequestException(
|
||||
SelfHostedOrganizationDetails selfHostedOrg,
|
||||
OrganizationLicense license,
|
||||
SutProvider<UpdateOrganizationLicenseCommand> sutProvider)
|
||||
{
|
||||
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
|
||||
globalSettings.LicenseDirectory = LicenseDirectory;
|
||||
globalSettings.SelfHosted = true;
|
||||
|
||||
// Setup license for CanUse validation
|
||||
license.Enabled = true;
|
||||
license.Issued = DateTime.Now.AddDays(-1);
|
||||
license.Expires = DateTime.Now.AddDays(1);
|
||||
license.Version = OrganizationLicense.CurrentLicenseFileVersion;
|
||||
license.LicenseType = LicenseType.Organization;
|
||||
license.Token = "test-token"; // Indicates this is a claims-based license
|
||||
sutProvider.GetDependency<ILicensingService>().VerifyLicense(license).Returns(true);
|
||||
|
||||
// Create a ClaimsPrincipal with WRONG installation ID
|
||||
var wrongInstallationId = Guid.NewGuid(); // Different from globalSettings.Installation.Id
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(OrganizationLicenseConstants.LicenseType, ((int)LicenseType.Organization).ToString()),
|
||||
new(OrganizationLicenseConstants.InstallationId, wrongInstallationId.ToString()),
|
||||
new(OrganizationLicenseConstants.Enabled, "true"),
|
||||
new(OrganizationLicenseConstants.SelfHost, "true")
|
||||
};
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims));
|
||||
|
||||
sutProvider.GetDependency<ILicensingService>()
|
||||
.GetClaimsPrincipalFromLicense(license)
|
||||
.Returns(claimsPrincipal);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null));
|
||||
|
||||
Assert.Contains("The installation ID does not match the current installation.", exception.Message);
|
||||
|
||||
// Verify organization was NOT saved
|
||||
await sutProvider.GetDependency<IOrganizationService>()
|
||||
.DidNotReceive()
|
||||
.ReplaceAndUpdateCacheAsync(Arg.Any<Organization>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateLicenseAsync_ExpiredLicenseWithoutClaims_ThrowsBadRequestException(
|
||||
SelfHostedOrganizationDetails selfHostedOrg,
|
||||
OrganizationLicense license,
|
||||
SutProvider<UpdateOrganizationLicenseCommand> sutProvider)
|
||||
{
|
||||
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
|
||||
globalSettings.LicenseDirectory = LicenseDirectory;
|
||||
globalSettings.SelfHosted = true;
|
||||
|
||||
// Setup legacy license (no Token, no claims)
|
||||
license.Token = null; // Legacy license
|
||||
license.Enabled = true;
|
||||
license.Issued = DateTime.Now.AddDays(-2);
|
||||
license.Expires = DateTime.Now.AddDays(-1); // Expired yesterday
|
||||
license.Version = OrganizationLicense.CurrentLicenseFileVersion;
|
||||
license.InstallationId = globalSettings.Installation.Id;
|
||||
license.LicenseType = LicenseType.Organization;
|
||||
license.SelfHost = true;
|
||||
|
||||
sutProvider.GetDependency<ILicensingService>().VerifyLicense(license).Returns(true);
|
||||
sutProvider.GetDependency<ILicensingService>()
|
||||
.GetClaimsPrincipalFromLicense(license)
|
||||
.Returns((ClaimsPrincipal)null); // No claims for legacy license
|
||||
|
||||
// Passing values for SelfHostedOrganizationDetails.CanUseLicense
|
||||
license.Seats = null;
|
||||
license.MaxCollections = null;
|
||||
license.UseGroups = true;
|
||||
license.UsePolicies = true;
|
||||
license.UseSso = true;
|
||||
license.UseKeyConnector = true;
|
||||
license.UseScim = true;
|
||||
license.UseCustomPermissions = true;
|
||||
license.UseResetPassword = true;
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null));
|
||||
|
||||
Assert.Contains("The license has expired.", exception.Message);
|
||||
|
||||
// Verify organization was NOT saved
|
||||
await sutProvider.GetDependency<IOrganizationService>()
|
||||
.DidNotReceive()
|
||||
.ReplaceAndUpdateCacheAsync(Arg.Any<Organization>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLicenseAsync_ExtractsAllClaimsBasedProperties_WhenClaimsPrincipalProvided()
|
||||
{
|
||||
// This test ensures that when new properties are added to OrganizationLicense,
|
||||
// they are automatically extracted from JWT claims in UpdateOrganizationLicenseCommand.
|
||||
// If a new constant is added to OrganizationLicenseConstants but not extracted,
|
||||
// this test will fail with a clear message showing which properties are missing.
|
||||
|
||||
// 1. Get all OrganizationLicenseConstants
|
||||
var constantFields = typeof(OrganizationLicenseConstants)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.GetField)
|
||||
.Where(f => f.IsLiteral && !f.IsInitOnly)
|
||||
.Select(f => f.GetValue(null) as string)
|
||||
.ToList();
|
||||
|
||||
// 2. Define properties that should be excluded (not claims-based or intentionally not extracted)
|
||||
var excludedProperties = new HashSet<string>
|
||||
{
|
||||
"Version", // Not in claims system (only in deprecated property-based licenses)
|
||||
"Hash", // Signature-related, not extracted from claims
|
||||
"Signature", // Signature-related, not extracted from claims
|
||||
"SignatureBytes", // Computed from Signature, not a claim
|
||||
"Token", // The JWT itself, not extracted from claims
|
||||
"Id" // Cloud org ID from license, not used - self-hosted org has its own separate ID
|
||||
};
|
||||
|
||||
// 3. Get properties that should be extracted from claims
|
||||
var propertiesThatShouldBeExtracted = constantFields
|
||||
.Where(c => !excludedProperties.Contains(c))
|
||||
.ToHashSet();
|
||||
|
||||
// 4. Read UpdateOrganizationLicenseCommand source code
|
||||
var commandSourcePath = Path.Combine(
|
||||
Directory.GetCurrentDirectory(),
|
||||
"..", "..", "..", "..", "..",
|
||||
"src", "Core", "Billing", "Organizations", "Commands", "UpdateOrganizationLicenseCommand.cs");
|
||||
var sourceCode = await File.ReadAllTextAsync(commandSourcePath);
|
||||
|
||||
// 5. Find all GetValue calls that extract properties from claims
|
||||
// Pattern matches: license.PropertyName = claimsPrincipal.GetValue<Type>(OrganizationLicenseConstants.PropertyName)
|
||||
var extractedProperties = new HashSet<string>();
|
||||
var getValuePattern = @"claimsPrincipal\.GetValue<[^>]+>\(OrganizationLicenseConstants\.(\w+)\)";
|
||||
var matches = Regex.Matches(sourceCode, getValuePattern);
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
extractedProperties.Add(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
// 6. Find missing extractions
|
||||
var missingExtractions = propertiesThatShouldBeExtracted
|
||||
.Except(extractedProperties)
|
||||
.OrderBy(p => p)
|
||||
.ToList();
|
||||
|
||||
// 7. Build error message with guidance if there are missing extractions
|
||||
var errorMessage = "";
|
||||
if (missingExtractions.Any())
|
||||
{
|
||||
errorMessage = $"The following constants in OrganizationLicenseConstants are NOT extracted from claims in UpdateOrganizationLicenseCommand:\n";
|
||||
errorMessage += string.Join("\n", missingExtractions.Select(p => $" - {p}"));
|
||||
errorMessage += "\n\nPlease add the following lines to UpdateOrganizationLicenseCommand.cs in the 'if (claimsPrincipal != null)' block:\n";
|
||||
foreach (var prop in missingExtractions)
|
||||
{
|
||||
errorMessage += $" license.{prop} = claimsPrincipal.GetValue<TYPE>(OrganizationLicenseConstants.{prop});\n";
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Assert - if this fails, the error message guides the developer to add the extraction
|
||||
// Note: We don't check for "extra extractions" because that would be a compile error
|
||||
// (can't reference OrganizationLicenseConstants.Foo if Foo doesn't exist)
|
||||
Assert.True(
|
||||
!missingExtractions.Any(),
|
||||
$"\n{errorMessage}");
|
||||
}
|
||||
|
||||
// Wrapper to compare 2 objects that are different types
|
||||
private bool AssertPropertyEqual(OrganizationLicense expected, Organization actual, params string[] excludedPropertyStrings)
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Subscriptions.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Services;
|
||||
@@ -29,6 +30,7 @@ namespace Bit.Core.Test.Billing.Premium.Commands;
|
||||
public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
{
|
||||
private readonly IBraintreeGateway _braintreeGateway = Substitute.For<IBraintreeGateway>();
|
||||
private readonly IBraintreeService _braintreeService = Substitute.For<IBraintreeService>();
|
||||
private readonly IGlobalSettings _globalSettings = Substitute.For<IGlobalSettings>();
|
||||
private readonly ISetupIntentCache _setupIntentCache = Substitute.For<ISetupIntentCache>();
|
||||
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
|
||||
@@ -59,6 +61,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
|
||||
_command = new CreatePremiumCloudHostedSubscriptionCommand(
|
||||
_braintreeGateway,
|
||||
_braintreeService,
|
||||
_globalSettings,
|
||||
_setupIntentCache,
|
||||
_stripeAdapter,
|
||||
@@ -235,11 +238,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockCustomer = Substitute.For<StripeCustomer>();
|
||||
mockCustomer.Id = "cust_123";
|
||||
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
|
||||
mockCustomer.Metadata = new Dictionary<string, string>();
|
||||
mockCustomer.Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123"
|
||||
};
|
||||
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "active";
|
||||
mockSubscription.LatestInvoiceId = "in_123";
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
@@ -258,6 +265,12 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
await _stripeAdapter.Received(1).CreateCustomerAsync(Arg.Any<CustomerCreateOptions>());
|
||||
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
|
||||
await _subscriberService.Received(1).CreateBraintreeCustomer(user, paymentMethod.Token);
|
||||
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
|
||||
Arg.Is<InvoiceUpdateOptions>(opts =>
|
||||
opts.AutoAdvance == false &&
|
||||
opts.Expand != null &&
|
||||
opts.Expand.Contains("customer")));
|
||||
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), mockInvoice);
|
||||
await _userService.Received(1).SaveUserAsync(user);
|
||||
await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id);
|
||||
}
|
||||
@@ -456,11 +469,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockCustomer = Substitute.For<StripeCustomer>();
|
||||
mockCustomer.Id = "cust_123";
|
||||
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
|
||||
mockCustomer.Metadata = new Dictionary<string, string>();
|
||||
mockCustomer.Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123"
|
||||
};
|
||||
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "incomplete";
|
||||
mockSubscription.LatestInvoiceId = "in_123";
|
||||
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
@@ -487,6 +504,12 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
Assert.True(result.IsT0);
|
||||
Assert.True(user.Premium);
|
||||
Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate);
|
||||
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
|
||||
Arg.Is<InvoiceUpdateOptions>(opts =>
|
||||
opts.AutoAdvance == false &&
|
||||
opts.Expand != null &&
|
||||
opts.Expand.Contains("customer")));
|
||||
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), mockInvoice);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -559,11 +582,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockCustomer = Substitute.For<StripeCustomer>();
|
||||
mockCustomer.Id = "cust_123";
|
||||
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
|
||||
mockCustomer.Metadata = new Dictionary<string, string>();
|
||||
mockCustomer.Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123"
|
||||
};
|
||||
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "active"; // PayPal + active doesn't match pattern
|
||||
mockSubscription.LatestInvoiceId = "in_123";
|
||||
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
@@ -590,6 +617,12 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
Assert.True(result.IsT0);
|
||||
Assert.False(user.Premium);
|
||||
Assert.Null(user.PremiumExpirationDate);
|
||||
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
|
||||
Arg.Is<InvoiceUpdateOptions>(opts =>
|
||||
opts.AutoAdvance == false &&
|
||||
opts.Expand != null &&
|
||||
opts.Expand.Contains("customer")));
|
||||
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), mockInvoice);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Subscriptions.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
@@ -8,6 +9,7 @@ using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
using static Bit.Core.Billing.Constants.StripeConstants;
|
||||
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
|
||||
using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable;
|
||||
|
||||
@@ -15,16 +17,15 @@ namespace Bit.Core.Test.Billing.Premium.Commands;
|
||||
|
||||
public class UpdatePremiumStorageCommandTests
|
||||
{
|
||||
private readonly IBraintreeService _braintreeService = Substitute.For<IBraintreeService>();
|
||||
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
|
||||
private readonly IUserService _userService = Substitute.For<IUserService>();
|
||||
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
|
||||
private readonly PremiumPlan _premiumPlan;
|
||||
private readonly UpdatePremiumStorageCommand _command;
|
||||
|
||||
public UpdatePremiumStorageCommandTests()
|
||||
{
|
||||
// Setup default premium plan with standard pricing
|
||||
_premiumPlan = new PremiumPlan
|
||||
var premiumPlan = new PremiumPlan
|
||||
{
|
||||
Name = "Premium",
|
||||
Available = true,
|
||||
@@ -32,29 +33,31 @@ public class UpdatePremiumStorageCommandTests
|
||||
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = "price_premium", Provided = 1 },
|
||||
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "price_storage", Provided = 1 }
|
||||
};
|
||||
_pricingClient.ListPremiumPlans().Returns(new List<PremiumPlan> { _premiumPlan });
|
||||
_pricingClient.ListPremiumPlans().Returns([premiumPlan]);
|
||||
|
||||
_command = new UpdatePremiumStorageCommand(
|
||||
_braintreeService,
|
||||
_stripeAdapter,
|
||||
_userService,
|
||||
_pricingClient,
|
||||
Substitute.For<ILogger<UpdatePremiumStorageCommand>>());
|
||||
}
|
||||
|
||||
private Subscription CreateMockSubscription(string subscriptionId, int? storageQuantity = null)
|
||||
private Subscription CreateMockSubscription(string subscriptionId, int? storageQuantity = null, bool isPayPal = false)
|
||||
{
|
||||
var items = new List<SubscriptionItem>();
|
||||
|
||||
// Always add the seat item
|
||||
items.Add(new SubscriptionItem
|
||||
var items = new List<SubscriptionItem>
|
||||
{
|
||||
Id = "si_seat",
|
||||
Price = new Price { Id = "price_premium" },
|
||||
Quantity = 1
|
||||
});
|
||||
// Always add the seat item
|
||||
new()
|
||||
{
|
||||
Id = "si_seat",
|
||||
Price = new Price { Id = "price_premium" },
|
||||
Quantity = 1
|
||||
}
|
||||
};
|
||||
|
||||
// Add storage item if quantity is provided
|
||||
if (storageQuantity.HasValue && storageQuantity.Value > 0)
|
||||
if (storageQuantity is > 0)
|
||||
{
|
||||
items.Add(new SubscriptionItem
|
||||
{
|
||||
@@ -64,9 +67,17 @@ public class UpdatePremiumStorageCommandTests
|
||||
});
|
||||
}
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "cus_123",
|
||||
Metadata = isPayPal ? new Dictionary<string, string> { { MetadataKeys.BraintreeCustomerId, "braintree_123" } } : new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
return new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
CustomerId = "cus_123",
|
||||
Customer = customer,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = items
|
||||
@@ -98,7 +109,7 @@ public class UpdatePremiumStorageCommandTests
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var subscription = CreateMockSubscription("sub_123", 4);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>()).Returns(subscription);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, -5);
|
||||
@@ -118,7 +129,7 @@ public class UpdatePremiumStorageCommandTests
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var subscription = CreateMockSubscription("sub_123", 4);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>()).Returns(subscription);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, 100);
|
||||
@@ -142,7 +153,7 @@ public class UpdatePremiumStorageCommandTests
|
||||
// Assert
|
||||
Assert.True(result.IsT1);
|
||||
var badRequest = result.AsT1;
|
||||
Assert.Equal("No access to storage.", badRequest.Response);
|
||||
Assert.Equal("User has no access to storage.", badRequest.Response);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -155,7 +166,7 @@ public class UpdatePremiumStorageCommandTests
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var subscription = CreateMockSubscription("sub_123", 9);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>()).Returns(subscription);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, 0);
|
||||
@@ -177,7 +188,7 @@ public class UpdatePremiumStorageCommandTests
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var subscription = CreateMockSubscription("sub_123", 4);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>()).Returns(subscription);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, 4);
|
||||
@@ -186,7 +197,7 @@ public class UpdatePremiumStorageCommandTests
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
// Verify subscription was fetched but NOT updated
|
||||
await _stripeAdapter.Received(1).GetSubscriptionAsync("sub_123");
|
||||
await _stripeAdapter.Received(1).GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>());
|
||||
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
|
||||
await _userService.DidNotReceive().SaveUserAsync(Arg.Any<User>());
|
||||
}
|
||||
@@ -201,7 +212,7 @@ public class UpdatePremiumStorageCommandTests
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var subscription = CreateMockSubscription("sub_123", 4);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>()).Returns(subscription);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, 9);
|
||||
@@ -216,7 +227,7 @@ public class UpdatePremiumStorageCommandTests
|
||||
opts.Items.Count == 1 &&
|
||||
opts.Items[0].Id == "si_storage" &&
|
||||
opts.Items[0].Quantity == 9 &&
|
||||
opts.ProrationBehavior == "create_prorations"));
|
||||
opts.ProrationBehavior == "always_invoice"));
|
||||
|
||||
// Verify user was saved
|
||||
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u =>
|
||||
@@ -233,8 +244,8 @@ public class UpdatePremiumStorageCommandTests
|
||||
user.Storage = 500L * 1024 * 1024;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var subscription = CreateMockSubscription("sub_123", null);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
|
||||
var subscription = CreateMockSubscription("sub_123");
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>()).Returns(subscription);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, 9);
|
||||
@@ -263,7 +274,7 @@ public class UpdatePremiumStorageCommandTests
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var subscription = CreateMockSubscription("sub_123", 9);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>()).Returns(subscription);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, 2);
|
||||
@@ -292,7 +303,7 @@ public class UpdatePremiumStorageCommandTests
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var subscription = CreateMockSubscription("sub_123", 9);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>()).Returns(subscription);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, 0);
|
||||
@@ -321,7 +332,7 @@ public class UpdatePremiumStorageCommandTests
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var subscription = CreateMockSubscription("sub_123", 4);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>()).Returns(subscription);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, 99);
|
||||
@@ -336,4 +347,200 @@ public class UpdatePremiumStorageCommandTests
|
||||
|
||||
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u => u.MaxStorageGb == 100));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_IncreaseStorage_PayPal_Success(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.MaxStorageGb = 5;
|
||||
user.Storage = 2L * 1024 * 1024 * 1024;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var subscription = CreateMockSubscription("sub_123", 4, isPayPal: true);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>()).Returns(subscription);
|
||||
|
||||
var draftInvoice = new Invoice { Id = "in_draft" };
|
||||
_stripeAdapter.CreateInvoiceAsync(Arg.Any<InvoiceCreateOptions>()).Returns(draftInvoice);
|
||||
|
||||
var finalizedInvoice = new Invoice
|
||||
{
|
||||
Id = "in_finalized",
|
||||
Customer = new Customer { Id = "cus_123" }
|
||||
};
|
||||
_stripeAdapter.FinalizeInvoiceAsync("in_draft", Arg.Any<InvoiceFinalizeOptions>()).Returns(finalizedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, 9);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
// Verify subscription was updated with CreateProrations
|
||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||
opts.Items.Count == 1 &&
|
||||
opts.Items[0].Id == "si_storage" &&
|
||||
opts.Items[0].Quantity == 9 &&
|
||||
opts.ProrationBehavior == "create_prorations"));
|
||||
|
||||
// Verify draft invoice was created
|
||||
await _stripeAdapter.Received(1).CreateInvoiceAsync(
|
||||
Arg.Is<InvoiceCreateOptions>(opts =>
|
||||
opts.Customer == "cus_123" &&
|
||||
opts.Subscription == "sub_123" &&
|
||||
opts.AutoAdvance == false &&
|
||||
opts.CollectionMethod == "charge_automatically"));
|
||||
|
||||
// Verify invoice was finalized
|
||||
await _stripeAdapter.Received(1).FinalizeInvoiceAsync(
|
||||
"in_draft",
|
||||
Arg.Is<InvoiceFinalizeOptions>(opts =>
|
||||
opts.AutoAdvance == false &&
|
||||
opts.Expand.Contains("customer")));
|
||||
|
||||
// Verify Braintree payment was processed
|
||||
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), finalizedInvoice);
|
||||
|
||||
// Verify user was saved
|
||||
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u =>
|
||||
u.Id == user.Id &&
|
||||
u.MaxStorageGb == 10));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_AddStorageFromZero_PayPal_Success(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.MaxStorageGb = 1;
|
||||
user.Storage = 500L * 1024 * 1024;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var subscription = CreateMockSubscription("sub_123", isPayPal: true);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>()).Returns(subscription);
|
||||
|
||||
var draftInvoice = new Invoice { Id = "in_draft" };
|
||||
_stripeAdapter.CreateInvoiceAsync(Arg.Any<InvoiceCreateOptions>()).Returns(draftInvoice);
|
||||
|
||||
var finalizedInvoice = new Invoice
|
||||
{
|
||||
Id = "in_finalized",
|
||||
Customer = new Customer { Id = "cus_123" }
|
||||
};
|
||||
_stripeAdapter.FinalizeInvoiceAsync("in_draft", Arg.Any<InvoiceFinalizeOptions>()).Returns(finalizedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, 9);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
// Verify subscription was updated with new storage item
|
||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||
opts.Items.Count == 1 &&
|
||||
opts.Items[0].Price == "price_storage" &&
|
||||
opts.Items[0].Quantity == 9 &&
|
||||
opts.ProrationBehavior == "create_prorations"));
|
||||
|
||||
// Verify invoice creation and payment flow
|
||||
await _stripeAdapter.Received(1).CreateInvoiceAsync(Arg.Any<InvoiceCreateOptions>());
|
||||
await _stripeAdapter.Received(1).FinalizeInvoiceAsync("in_draft", Arg.Any<InvoiceFinalizeOptions>());
|
||||
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), finalizedInvoice);
|
||||
|
||||
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u => u.MaxStorageGb == 10));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_DecreaseStorage_PayPal_Success(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.MaxStorageGb = 10;
|
||||
user.Storage = 2L * 1024 * 1024 * 1024;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var subscription = CreateMockSubscription("sub_123", 9, isPayPal: true);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>()).Returns(subscription);
|
||||
|
||||
var draftInvoice = new Invoice { Id = "in_draft" };
|
||||
_stripeAdapter.CreateInvoiceAsync(Arg.Any<InvoiceCreateOptions>()).Returns(draftInvoice);
|
||||
|
||||
var finalizedInvoice = new Invoice
|
||||
{
|
||||
Id = "in_finalized",
|
||||
Customer = new Customer { Id = "cus_123" }
|
||||
};
|
||||
_stripeAdapter.FinalizeInvoiceAsync("in_draft", Arg.Any<InvoiceFinalizeOptions>()).Returns(finalizedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, 2);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
// Verify subscription was updated
|
||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||
opts.Items.Count == 1 &&
|
||||
opts.Items[0].Id == "si_storage" &&
|
||||
opts.Items[0].Quantity == 2 &&
|
||||
opts.ProrationBehavior == "create_prorations"));
|
||||
|
||||
// Verify invoice creation and payment flow
|
||||
await _stripeAdapter.Received(1).CreateInvoiceAsync(Arg.Any<InvoiceCreateOptions>());
|
||||
await _stripeAdapter.Received(1).FinalizeInvoiceAsync("in_draft", Arg.Any<InvoiceFinalizeOptions>());
|
||||
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), finalizedInvoice);
|
||||
|
||||
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u => u.MaxStorageGb == 3));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_RemoveAllAdditionalStorage_PayPal_Success(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.MaxStorageGb = 10;
|
||||
user.Storage = 500L * 1024 * 1024;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var subscription = CreateMockSubscription("sub_123", 9, isPayPal: true);
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>()).Returns(subscription);
|
||||
|
||||
var draftInvoice = new Invoice { Id = "in_draft" };
|
||||
_stripeAdapter.CreateInvoiceAsync(Arg.Any<InvoiceCreateOptions>()).Returns(draftInvoice);
|
||||
|
||||
var finalizedInvoice = new Invoice
|
||||
{
|
||||
Id = "in_finalized",
|
||||
Customer = new Customer { Id = "cus_123" }
|
||||
};
|
||||
_stripeAdapter.FinalizeInvoiceAsync("in_draft", Arg.Any<InvoiceFinalizeOptions>()).Returns(finalizedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, 0);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
// Verify subscription item was deleted
|
||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||
opts.Items.Count == 1 &&
|
||||
opts.Items[0].Id == "si_storage" &&
|
||||
opts.Items[0].Deleted == true &&
|
||||
opts.ProrationBehavior == "create_prorations"));
|
||||
|
||||
// Verify invoice creation and payment flow
|
||||
await _stripeAdapter.Received(1).CreateInvoiceAsync(Arg.Any<InvoiceCreateOptions>());
|
||||
await _stripeAdapter.Received(1).FinalizeInvoiceAsync("in_draft", Arg.Any<InvoiceFinalizeOptions>());
|
||||
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), finalizedInvoice);
|
||||
|
||||
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u => u.MaxStorageGb == 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Subscriptions.Models;
|
||||
using Bit.Core.Billing.Subscriptions.Queries;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Subscriptions.Queries;
|
||||
|
||||
using static StripeConstants;
|
||||
|
||||
public class GetBitwardenSubscriptionQueryTests
|
||||
{
|
||||
private readonly ILogger<GetBitwardenSubscriptionQuery> _logger = Substitute.For<ILogger<GetBitwardenSubscriptionQuery>>();
|
||||
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
|
||||
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
|
||||
private readonly GetBitwardenSubscriptionQuery _query;
|
||||
|
||||
public GetBitwardenSubscriptionQueryTests()
|
||||
{
|
||||
_query = new GetBitwardenSubscriptionQuery(
|
||||
_logger,
|
||||
_pricingClient,
|
||||
_stripeAdapter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_IncompleteStatus_ReturnsBitwardenSubscriptionWithSuspension()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Incomplete);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.Incomplete, result.Status);
|
||||
Assert.NotNull(result.Suspension);
|
||||
Assert.Equal(subscription.Created.AddHours(23), result.Suspension);
|
||||
Assert.Equal(1, result.GracePeriod);
|
||||
Assert.Null(result.NextCharge);
|
||||
Assert.Null(result.CancelAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_IncompleteExpiredStatus_ReturnsBitwardenSubscriptionWithSuspension()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.IncompleteExpired);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.IncompleteExpired, result.Status);
|
||||
Assert.NotNull(result.Suspension);
|
||||
Assert.Equal(subscription.Created.AddHours(23), result.Suspension);
|
||||
Assert.Equal(1, result.GracePeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_TrialingStatus_ReturnsBitwardenSubscriptionWithNextCharge()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Trialing);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.Trialing, result.Status);
|
||||
Assert.NotNull(result.NextCharge);
|
||||
Assert.Equal(subscription.Items.First().CurrentPeriodEnd, result.NextCharge);
|
||||
Assert.Null(result.Suspension);
|
||||
Assert.Null(result.GracePeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_ActiveStatus_ReturnsBitwardenSubscriptionWithNextCharge()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.Active, result.Status);
|
||||
Assert.NotNull(result.NextCharge);
|
||||
Assert.Equal(subscription.Items.First().CurrentPeriodEnd, result.NextCharge);
|
||||
Assert.Null(result.Suspension);
|
||||
Assert.Null(result.GracePeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_ActiveStatusWithCancelAt_ReturnsCancelAt()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var cancelAt = DateTime.UtcNow.AddMonths(1);
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active, cancelAt: cancelAt);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.Active, result.Status);
|
||||
Assert.Equal(cancelAt, result.CancelAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_PastDueStatus_WithOpenInvoices_ReturnsSuspension()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.PastDue, collectionMethod: "charge_automatically");
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
var openInvoice = CreateInvoice();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
_stripeAdapter.SearchInvoiceAsync(Arg.Any<InvoiceSearchOptions>())
|
||||
.Returns([openInvoice]);
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.PastDue, result.Status);
|
||||
Assert.NotNull(result.Suspension);
|
||||
Assert.Equal(openInvoice.Created.AddDays(14), result.Suspension);
|
||||
Assert.Equal(14, result.GracePeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_PastDueStatus_WithoutOpenInvoices_ReturnsNoSuspension()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.PastDue);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
_stripeAdapter.SearchInvoiceAsync(Arg.Any<InvoiceSearchOptions>())
|
||||
.Returns([]);
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.PastDue, result.Status);
|
||||
Assert.Null(result.Suspension);
|
||||
Assert.Null(result.GracePeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_UnpaidStatus_WithOpenInvoices_ReturnsSuspension()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Unpaid, collectionMethod: "charge_automatically");
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
var openInvoice = CreateInvoice();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
_stripeAdapter.SearchInvoiceAsync(Arg.Any<InvoiceSearchOptions>())
|
||||
.Returns([openInvoice]);
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.Unpaid, result.Status);
|
||||
Assert.NotNull(result.Suspension);
|
||||
Assert.Equal(14, result.GracePeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_CanceledStatus_ReturnsCanceledDate()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var canceledAt = DateTime.UtcNow.AddDays(-5);
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Canceled, canceledAt: canceledAt);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.Canceled, result.Status);
|
||||
Assert.Equal(canceledAt, result.Canceled);
|
||||
Assert.Null(result.Suspension);
|
||||
Assert.Null(result.NextCharge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_UnmanagedStatus_ThrowsConflictException()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription("unmanaged_status");
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
await Assert.ThrowsAsync<ConflictException>(() => _query.Run(user));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_WithAdditionalStorage_IncludesStorageInCart()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: true);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Cart.PasswordManager.AdditionalStorage);
|
||||
Assert.Equal("additionalStorageGB", result.Cart.PasswordManager.AdditionalStorage.TranslationKey);
|
||||
Assert.Equal(2, result.Cart.PasswordManager.AdditionalStorage.Quantity);
|
||||
Assert.NotNull(result.Storage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_WithoutAdditionalStorage_ExcludesStorageFromCart()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: false);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.Cart.PasswordManager.AdditionalStorage);
|
||||
Assert.NotNull(result.Storage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_WithCartLevelDiscount_IncludesDiscountInCart()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active);
|
||||
subscription.Customer.Discount = CreateDiscount(discountType: "cart");
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Cart.Discount);
|
||||
Assert.Equal(BitwardenDiscountType.PercentOff, result.Cart.Discount.Type);
|
||||
Assert.Equal(20, result.Cart.Discount.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_WithProductLevelDiscount_IncludesDiscountInCartItem()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active);
|
||||
var productDiscount = CreateDiscount(discountType: "product", productId: "prod_premium_seat");
|
||||
subscription.Discounts = [productDiscount];
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Cart.PasswordManager.Seats.Discount);
|
||||
Assert.Equal(BitwardenDiscountType.PercentOff, result.Cart.PasswordManager.Seats.Discount.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_WithoutMaxStorageGb_ReturnsNullStorage()
|
||||
{
|
||||
var user = CreateUser();
|
||||
user.MaxStorageGb = null;
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.Storage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_CalculatesStorageCorrectly()
|
||||
{
|
||||
var user = CreateUser();
|
||||
user.Storage = 5368709120; // 5 GB in bytes
|
||||
user.MaxStorageGb = 10;
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: true);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Storage);
|
||||
Assert.Equal(10, result.Storage.Available);
|
||||
Assert.Equal(5.0, result.Storage.Used);
|
||||
Assert.NotEmpty(result.Storage.ReadableUsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_TaxEstimation_WithInvoiceUpcomingNoneError_ReturnsZeroTax()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.ThrowsAsync(new StripeException { StripeError = new StripeError { Code = ErrorCodes.InvoiceUpcomingNone } });
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0, result.Cart.EstimatedTax);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_MissingPasswordManagerSeatsItem_ThrowsConflictException()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active);
|
||||
subscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = []
|
||||
};
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
|
||||
await Assert.ThrowsAsync<ConflictException>(() => _query.Run(user));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_IncludesEstimatedTax()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
var invoice = CreateInvoicePreview(totalTax: 500); // $5.00 tax
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(invoice);
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(5.0m, result.Cart.EstimatedTax);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_SetsCadenceToAnnually()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(PlanCadenceType.Annually, result.Cart.Cadence);
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static User CreateUser()
|
||||
{
|
||||
return new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GatewaySubscriptionId = "sub_test123",
|
||||
MaxStorageGb = 1,
|
||||
Storage = 1073741824 // 1 GB in bytes
|
||||
};
|
||||
}
|
||||
|
||||
private static Subscription CreateSubscription(
|
||||
string status,
|
||||
bool includeStorage = false,
|
||||
DateTime? cancelAt = null,
|
||||
DateTime? canceledAt = null,
|
||||
string collectionMethod = "charge_automatically")
|
||||
{
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var items = new List<SubscriptionItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "si_premium_seat",
|
||||
Price = new Price
|
||||
{
|
||||
Id = "price_premium_seat",
|
||||
UnitAmountDecimal = 1000,
|
||||
Product = new Product { Id = "prod_premium_seat" }
|
||||
},
|
||||
Quantity = 1,
|
||||
CurrentPeriodStart = DateTime.UtcNow,
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
}
|
||||
};
|
||||
|
||||
if (includeStorage)
|
||||
{
|
||||
items.Add(new SubscriptionItem
|
||||
{
|
||||
Id = "si_storage",
|
||||
Price = new Price
|
||||
{
|
||||
Id = "price_storage",
|
||||
UnitAmountDecimal = 400,
|
||||
Product = new Product { Id = "prod_storage" }
|
||||
},
|
||||
Quantity = 2,
|
||||
CurrentPeriodStart = DateTime.UtcNow,
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
});
|
||||
}
|
||||
|
||||
return new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = status,
|
||||
Created = DateTime.UtcNow.AddMonths(-1),
|
||||
Customer = new Customer
|
||||
{
|
||||
Id = "cus_test123",
|
||||
Discount = null
|
||||
},
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = items
|
||||
},
|
||||
CancelAt = cancelAt,
|
||||
CanceledAt = canceledAt,
|
||||
CollectionMethod = collectionMethod,
|
||||
Discounts = []
|
||||
};
|
||||
}
|
||||
|
||||
private static List<Bit.Core.Billing.Pricing.Premium.Plan> CreatePremiumPlans()
|
||||
{
|
||||
return
|
||||
[
|
||||
new()
|
||||
{
|
||||
Name = "Premium",
|
||||
Available = true,
|
||||
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
|
||||
{
|
||||
StripePriceId = "price_premium_seat",
|
||||
Price = 10.0m,
|
||||
Provided = 1
|
||||
},
|
||||
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
|
||||
{
|
||||
StripePriceId = "price_storage",
|
||||
Price = 4.0m,
|
||||
Provided = 1
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private static Invoice CreateInvoice()
|
||||
{
|
||||
return new Invoice
|
||||
{
|
||||
Id = "in_test123",
|
||||
Created = DateTime.UtcNow.AddDays(-10),
|
||||
PeriodEnd = DateTime.UtcNow.AddDays(-5),
|
||||
Attempted = true,
|
||||
Status = "open"
|
||||
};
|
||||
}
|
||||
|
||||
private static Invoice CreateInvoicePreview(long totalTax = 0)
|
||||
{
|
||||
var taxes = totalTax > 0
|
||||
? new List<InvoiceTotalTax> { new() { Amount = totalTax } }
|
||||
: new List<InvoiceTotalTax>();
|
||||
|
||||
return new Invoice
|
||||
{
|
||||
Id = "in_preview",
|
||||
TotalTaxes = taxes
|
||||
};
|
||||
}
|
||||
|
||||
private static Discount CreateDiscount(string discountType = "cart", string? productId = null)
|
||||
{
|
||||
var coupon = new Coupon
|
||||
{
|
||||
Valid = true,
|
||||
PercentOff = 20,
|
||||
AppliesTo = discountType == "product" && productId != null
|
||||
? new CouponAppliesTo { Products = [productId] }
|
||||
: new CouponAppliesTo { Products = [] }
|
||||
};
|
||||
|
||||
return new Discount
|
||||
{
|
||||
Coupon = coupon
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Bit.Core.Test</RootNamespace>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
|
||||
@@ -30,7 +32,7 @@
|
||||
<ItemGroup>
|
||||
<!-- Email templates uses .hbs extension, they must be included for emails to work -->
|
||||
<EmbeddedResource Include="**\*.hbs" />
|
||||
|
||||
|
||||
<EmbeddedResource Include="Utilities\data\embeddedResource.txt" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
211
test/Core.Test/Services/PlayIdServiceTests.cs
Normal file
211
test/Core.Test/Services/PlayIdServiceTests.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Services;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class PlayIdServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void InPlay_WhenPlayIdSetAndDevelopment_ReturnsTrue(
|
||||
string playId,
|
||||
SutProvider<PlayIdService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IHostEnvironment>().EnvironmentName.Returns(Environments.Development);
|
||||
sutProvider.Sut.PlayId = playId;
|
||||
|
||||
var result = sutProvider.Sut.InPlay(out var resultPlayId);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(playId, resultPlayId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void InPlay_WhenPlayIdSetButNotDevelopment_ReturnsFalse(
|
||||
string playId,
|
||||
SutProvider<PlayIdService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IHostEnvironment>().EnvironmentName.Returns(Environments.Production);
|
||||
sutProvider.Sut.PlayId = playId;
|
||||
|
||||
var result = sutProvider.Sut.InPlay(out var resultPlayId);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(playId, resultPlayId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData((string?)null)]
|
||||
[BitAutoData("")]
|
||||
public void InPlay_WhenPlayIdNullOrEmptyAndDevelopment_ReturnsFalse(
|
||||
string? playId,
|
||||
SutProvider<PlayIdService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IHostEnvironment>().EnvironmentName.Returns(Environments.Development);
|
||||
sutProvider.Sut.PlayId = playId;
|
||||
|
||||
var result = sutProvider.Sut.InPlay(out var resultPlayId);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Empty(resultPlayId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void PlayId_CanGetAndSet(string playId)
|
||||
{
|
||||
var hostEnvironment = Substitute.For<IHostEnvironment>();
|
||||
var sut = new PlayIdService(hostEnvironment);
|
||||
|
||||
sut.PlayId = playId;
|
||||
|
||||
Assert.Equal(playId, sut.PlayId);
|
||||
}
|
||||
}
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class NeverPlayIdServicesTests
|
||||
{
|
||||
[Fact]
|
||||
public void InPlay_ReturnsFalse()
|
||||
{
|
||||
var sut = new NeverPlayIdServices();
|
||||
|
||||
var result = sut.InPlay(out var playId);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Empty(playId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("test-play-id")]
|
||||
[InlineData(null)]
|
||||
public void PlayId_SetterDoesNothing_GetterReturnsNull(string? value)
|
||||
{
|
||||
var sut = new NeverPlayIdServices();
|
||||
|
||||
sut.PlayId = value;
|
||||
|
||||
Assert.Null(sut.PlayId);
|
||||
}
|
||||
}
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class PlayIdSingletonServiceTests
|
||||
{
|
||||
public static IEnumerable<object[]> SutProvider()
|
||||
{
|
||||
var sutProvider = new SutProvider<PlayIdSingletonService>();
|
||||
var httpContext = sutProvider.CreateDependency<HttpContext>();
|
||||
var serviceProvider = sutProvider.CreateDependency<IServiceProvider>();
|
||||
var hostEnvironment = sutProvider.CreateDependency<IHostEnvironment>();
|
||||
var playIdService = new PlayIdService(hostEnvironment);
|
||||
sutProvider.SetDependency(playIdService);
|
||||
httpContext.RequestServices.Returns(serviceProvider);
|
||||
serviceProvider.GetService<PlayIdService>().Returns(playIdService);
|
||||
serviceProvider.GetRequiredService<PlayIdService>().Returns(playIdService);
|
||||
sutProvider.CreateDependency<IHttpContextAccessor>().HttpContext.Returns(httpContext);
|
||||
sutProvider.Create();
|
||||
return [[sutProvider]];
|
||||
}
|
||||
|
||||
private void PrepHttpContext(
|
||||
SutProvider<PlayIdSingletonService> sutProvider)
|
||||
{
|
||||
var httpContext = sutProvider.CreateDependency<HttpContext>();
|
||||
var serviceProvider = sutProvider.CreateDependency<IServiceProvider>();
|
||||
var PlayIdService = sutProvider.CreateDependency<PlayIdService>();
|
||||
httpContext.RequestServices.Returns(serviceProvider);
|
||||
serviceProvider.GetRequiredService<PlayIdService>().Returns(PlayIdService);
|
||||
sutProvider.GetDependency<IHttpContextAccessor>().HttpContext.Returns(httpContext);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitMemberAutoData(nameof(SutProvider))]
|
||||
public void InPlay_WhenNoHttpContext_ReturnsFalse(
|
||||
SutProvider<PlayIdSingletonService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IHttpContextAccessor>().HttpContext.Returns((HttpContext?)null);
|
||||
sutProvider.GetDependency<IHostEnvironment>().EnvironmentName.Returns(Environments.Development);
|
||||
|
||||
var result = sutProvider.Sut.InPlay(out var playId);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Empty(playId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitMemberAutoData(nameof(SutProvider))]
|
||||
public void InPlay_WhenNotDevelopment_ReturnsFalse(
|
||||
SutProvider<PlayIdSingletonService> sutProvider,
|
||||
string playIdValue)
|
||||
{
|
||||
var scopedPlayIdService = sutProvider.GetDependency<PlayIdService>();
|
||||
scopedPlayIdService.PlayId = playIdValue;
|
||||
sutProvider.GetDependency<IHostEnvironment>().EnvironmentName.Returns(Environments.Production);
|
||||
|
||||
var result = sutProvider.Sut.InPlay(out var playId);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Empty(playId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitMemberAutoData(nameof(SutProvider))]
|
||||
public void InPlay_WhenDevelopmentAndHttpContextWithPlayId_ReturnsTrue(
|
||||
SutProvider<PlayIdSingletonService> sutProvider,
|
||||
string playIdValue)
|
||||
{
|
||||
sutProvider.GetDependency<PlayIdService>().PlayId = playIdValue;
|
||||
sutProvider.GetDependency<IHostEnvironment>().EnvironmentName.Returns(Environments.Development);
|
||||
|
||||
var result = sutProvider.Sut.InPlay(out var playId);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(playIdValue, playId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitMemberAutoData(nameof(SutProvider))]
|
||||
public void PlayId_SetterSetsOnScopedService(
|
||||
SutProvider<PlayIdSingletonService> sutProvider,
|
||||
string playIdValue)
|
||||
{
|
||||
var scopedPlayIdService = sutProvider.GetDependency<PlayIdService>();
|
||||
|
||||
sutProvider.Sut.PlayId = playIdValue;
|
||||
|
||||
Assert.Equal(playIdValue, scopedPlayIdService.PlayId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitMemberAutoData(nameof(SutProvider))]
|
||||
public void PlayId_WhenNoHttpContext_GetterReturnsNull(
|
||||
SutProvider<PlayIdSingletonService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IHttpContextAccessor>().HttpContext.Returns((HttpContext?)null);
|
||||
|
||||
var result = sutProvider.Sut.PlayId;
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitMemberAutoData(nameof(SutProvider))]
|
||||
public void PlayId_WhenNoHttpContext_SetterDoesNotThrow(
|
||||
SutProvider<PlayIdSingletonService> sutProvider,
|
||||
string playIdValue)
|
||||
{
|
||||
sutProvider.GetDependency<IHttpContextAccessor>().HttpContext.Returns((HttpContext?)null);
|
||||
|
||||
sutProvider.Sut.PlayId = playIdValue;
|
||||
}
|
||||
}
|
||||
143
test/Core.Test/Services/PlayItemServiceTests.cs
Normal file
143
test/Core.Test/Services/PlayItemServiceTests.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Services;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class PlayItemServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task Record_User_WhenInPlay_RecordsPlayItem(
|
||||
string playId,
|
||||
User user,
|
||||
SutProvider<PlayItemService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IPlayIdService>()
|
||||
.InPlay(out Arg.Any<string>())
|
||||
.Returns(x =>
|
||||
{
|
||||
x[0] = playId;
|
||||
return true;
|
||||
});
|
||||
|
||||
await sutProvider.Sut.Record(user);
|
||||
|
||||
await sutProvider.GetDependency<IPlayItemRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync(Arg.Is<PlayItem>(pd =>
|
||||
pd.PlayId == playId &&
|
||||
pd.UserId == user.Id &&
|
||||
pd.OrganizationId == null));
|
||||
|
||||
sutProvider.GetDependency<ILogger<PlayItemService>>()
|
||||
.Received(1)
|
||||
.Log(
|
||||
LogLevel.Information,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Is<object>(o => o.ToString().Contains(user.Id.ToString()) && o.ToString().Contains(playId)),
|
||||
null,
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task Record_User_WhenNotInPlay_DoesNotRecordPlayItem(
|
||||
User user,
|
||||
SutProvider<PlayItemService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IPlayIdService>()
|
||||
.InPlay(out Arg.Any<string>())
|
||||
.Returns(x =>
|
||||
{
|
||||
x[0] = null;
|
||||
return false;
|
||||
});
|
||||
|
||||
await sutProvider.Sut.Record(user);
|
||||
|
||||
await sutProvider.GetDependency<IPlayItemRepository>()
|
||||
.DidNotReceive()
|
||||
.CreateAsync(Arg.Any<PlayItem>());
|
||||
|
||||
sutProvider.GetDependency<ILogger<PlayItemService>>()
|
||||
.DidNotReceive()
|
||||
.Log(
|
||||
LogLevel.Information,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Any<object>(),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task Record_Organization_WhenInPlay_RecordsPlayItem(
|
||||
string playId,
|
||||
Organization organization,
|
||||
SutProvider<PlayItemService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IPlayIdService>()
|
||||
.InPlay(out Arg.Any<string>())
|
||||
.Returns(x =>
|
||||
{
|
||||
x[0] = playId;
|
||||
return true;
|
||||
});
|
||||
|
||||
await sutProvider.Sut.Record(organization);
|
||||
|
||||
await sutProvider.GetDependency<IPlayItemRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync(Arg.Is<PlayItem>(pd =>
|
||||
pd.PlayId == playId &&
|
||||
pd.OrganizationId == organization.Id &&
|
||||
pd.UserId == null));
|
||||
|
||||
sutProvider.GetDependency<ILogger<PlayItemService>>()
|
||||
.Received(1)
|
||||
.Log(
|
||||
LogLevel.Information,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Is<object>(o => o.ToString().Contains(organization.Id.ToString()) && o.ToString().Contains(playId)),
|
||||
null,
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task Record_Organization_WhenNotInPlay_DoesNotRecordPlayItem(
|
||||
Organization organization,
|
||||
SutProvider<PlayItemService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IPlayIdService>()
|
||||
.InPlay(out Arg.Any<string>())
|
||||
.Returns(x =>
|
||||
{
|
||||
x[0] = null;
|
||||
return false;
|
||||
});
|
||||
|
||||
await sutProvider.Sut.Record(organization);
|
||||
|
||||
await sutProvider.GetDependency<IPlayItemRepository>()
|
||||
.DidNotReceive()
|
||||
.CreateAsync(Arg.Any<PlayItem>());
|
||||
|
||||
sutProvider.GetDependency<ILogger<PlayItemService>>()
|
||||
.DidNotReceive()
|
||||
.Log(
|
||||
LogLevel.Information,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Any<object>(),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,43 @@ public class ImportCiphersAsyncCommandTests
|
||||
Assert.Equal("You cannot import items into your personal vault because you are a member of an organization which forbids it.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportIntoIndividualVaultAsync_FavoriteCiphers_PersistsFavoriteInfo(
|
||||
Guid importingUserId,
|
||||
List<CipherDetails> ciphers,
|
||||
SutProvider<ImportCiphersCommand> sutProvider
|
||||
)
|
||||
{
|
||||
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>();
|
||||
var folderRelationships = new List<KeyValuePair<int, int>>();
|
||||
|
||||
ciphers.ForEach(c =>
|
||||
{
|
||||
c.UserId = importingUserId;
|
||||
c.Favorite = true;
|
||||
});
|
||||
|
||||
await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId);
|
||||
|
||||
await sutProvider.GetDependency<ICipherRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync(importingUserId, Arg.Is<IEnumerable<Cipher>>(ciphers => ciphers.All(c => c.Favorites == $"{{\"{importingUserId.ToString().ToUpperInvariant()}\":true}}")), Arg.Any<List<Folder>>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportIntoOrganizationalVaultAsync_Success(
|
||||
Organization organization,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.Repositories;
|
||||
using Bit.Core.Tools.SendFeatures.Queries;
|
||||
@@ -47,7 +48,7 @@ public class SendAuthenticationQueryTests
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: emailString, password: null);
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: emailString, password: null, AuthType.Email);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
// Act
|
||||
@@ -63,7 +64,7 @@ public class SendAuthenticationQueryTests
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: "hashedpassword");
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: "hashedpassword", AuthType.Email);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
// Act
|
||||
@@ -78,7 +79,7 @@ public class SendAuthenticationQueryTests
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null);
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null, AuthType.None);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
// Act
|
||||
@@ -105,11 +106,11 @@ public class SendAuthenticationQueryTests
|
||||
public static IEnumerable<object[]> AuthenticationMethodTestCases()
|
||||
{
|
||||
yield return new object[] { null, typeof(NeverAuthenticate) };
|
||||
yield return new object[] { CreateSend(accessCount: 5, maxAccessCount: 5, emails: null, password: null), typeof(NeverAuthenticate) };
|
||||
yield return new object[] { CreateSend(accessCount: 6, maxAccessCount: 5, emails: null, password: null), typeof(NeverAuthenticate) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: null), typeof(EmailOtp) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: "hashedpassword"), typeof(ResourcePassword) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null), typeof(NotAuthenticated) };
|
||||
yield return new object[] { CreateSend(accessCount: 5, maxAccessCount: 5, emails: null, password: null, AuthType.None), typeof(NeverAuthenticate) };
|
||||
yield return new object[] { CreateSend(accessCount: 6, maxAccessCount: 5, emails: null, password: null, AuthType.None), typeof(NeverAuthenticate) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: null, AuthType.Email), typeof(EmailOtp) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: "hashedpassword", AuthType.Password), typeof(ResourcePassword) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null, AuthType.None), typeof(NotAuthenticated) };
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> EmailParsingTestCases()
|
||||
@@ -121,7 +122,7 @@ public class SendAuthenticationQueryTests
|
||||
yield return new object[] { " , test@example.com, ,other@example.com, ", new[] { "test@example.com", "other@example.com" } };
|
||||
}
|
||||
|
||||
private static Send CreateSend(int accessCount, int? maxAccessCount, string? emails, string? password)
|
||||
private static Send CreateSend(int accessCount, int? maxAccessCount, string? emails, string? password, AuthType? authType)
|
||||
{
|
||||
return new Send
|
||||
{
|
||||
@@ -129,7 +130,8 @@ public class SendAuthenticationQueryTests
|
||||
AccessCount = accessCount,
|
||||
MaxAccessCount = maxAccessCount,
|
||||
Emails = emails,
|
||||
Password = password
|
||||
Password = password,
|
||||
AuthType = authType
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ namespace Bit.Core.Test.Tools.Services;
|
||||
public class SendOwnerQueryTests
|
||||
{
|
||||
private readonly ISendRepository _sendRepository;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly SendOwnerQuery _sendOwnerQuery;
|
||||
private readonly Guid _currentUserId = Guid.NewGuid();
|
||||
@@ -21,11 +20,10 @@ public class SendOwnerQueryTests
|
||||
public SendOwnerQueryTests()
|
||||
{
|
||||
_sendRepository = Substitute.For<ISendRepository>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_userService = Substitute.For<IUserService>();
|
||||
_user = new ClaimsPrincipal();
|
||||
_userService.GetProperUserId(_user).Returns(_currentUserId);
|
||||
_sendOwnerQuery = new SendOwnerQuery(_sendRepository, _featureService, _userService);
|
||||
_sendOwnerQuery = new SendOwnerQuery(_sendRepository, _userService);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -84,7 +82,7 @@ public class SendOwnerQueryTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOwned_WithFeatureFlagEnabled_ReturnsAllSends()
|
||||
public async Task GetOwned_ReturnsAllSendsIncludingEmailOTP()
|
||||
{
|
||||
// Arrange
|
||||
var sends = new List<Send>
|
||||
@@ -94,7 +92,6 @@ public class SendOwnerQueryTests
|
||||
CreateSend(Guid.NewGuid(), _currentUserId, emails: "other@example.com")
|
||||
};
|
||||
_sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(sends);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await _sendOwnerQuery.GetOwned(_user);
|
||||
@@ -105,28 +102,6 @@ public class SendOwnerQueryTests
|
||||
Assert.Contains(sends[1], result);
|
||||
Assert.Contains(sends[2], result);
|
||||
await _sendRepository.Received(1).GetManyByUserIdAsync(_currentUserId);
|
||||
_featureService.Received(1).IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOwned_WithFeatureFlagDisabled_FiltersOutEmailOtpSends()
|
||||
{
|
||||
// Arrange
|
||||
var sendWithoutEmails = CreateSend(Guid.NewGuid(), _currentUserId, emails: null);
|
||||
var sendWithEmails = CreateSend(Guid.NewGuid(), _currentUserId, emails: "test@example.com");
|
||||
var sends = new List<Send> { sendWithoutEmails, sendWithEmails };
|
||||
_sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(sends);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(false);
|
||||
|
||||
// Act
|
||||
var result = await _sendOwnerQuery.GetOwned(_user);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Contains(sendWithoutEmails, result);
|
||||
Assert.DoesNotContain(sendWithEmails, result);
|
||||
await _sendRepository.Received(1).GetManyByUserIdAsync(_currentUserId);
|
||||
_featureService.Received(1).IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -147,7 +122,6 @@ public class SendOwnerQueryTests
|
||||
// Arrange
|
||||
var emptySends = new List<Send>();
|
||||
_sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(emptySends);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await _sendOwnerQuery.GetOwned(_user);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
84
test/Core.Test/Utilities/DomainNameAttributeTests.cs
Normal file
84
test/Core.Test/Utilities/DomainNameAttributeTests.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using Bit.Core.Utilities;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Utilities;
|
||||
|
||||
public class DomainNameValidatorAttributeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("example.com")] // basic domain
|
||||
[InlineData("sub.example.com")] // subdomain
|
||||
[InlineData("sub.sub2.example.com")] // multiple subdomains
|
||||
[InlineData("example-dash.com")] // domain with dash
|
||||
[InlineData("123example.com")] // domain starting with number
|
||||
[InlineData("example123.com")] // domain with numbers
|
||||
[InlineData("e.com")] // short domain
|
||||
[InlineData("very-long-subdomain-name.example.com")] // long subdomain
|
||||
[InlineData("wörldé.com")] // unicode domain (IDN)
|
||||
public void IsValid_ReturnsTrueWhenValid(string domainName)
|
||||
{
|
||||
var sut = new DomainNameValidatorAttribute();
|
||||
|
||||
var actual = sut.IsValid(domainName);
|
||||
|
||||
Assert.True(actual);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("<script>alert('xss')</script>")] // XSS attempt
|
||||
[InlineData("example.com<script>")] // XSS suffix
|
||||
[InlineData("<img src=x>")] // HTML tag
|
||||
[InlineData("example.com\t")] // trailing tab
|
||||
[InlineData("\texample.com")] // leading tab
|
||||
[InlineData("exam\tple.com")] // middle tab
|
||||
[InlineData("example.com\n")] // newline
|
||||
[InlineData("example.com\r")] // carriage return
|
||||
[InlineData("example.com\b")] // backspace
|
||||
[InlineData("exam ple.com")] // space in domain
|
||||
[InlineData("example.com ")] // trailing space (after trim, becomes valid, but with space it's invalid)
|
||||
[InlineData(" example.com")] // leading space (after trim, becomes valid, but with space it's invalid)
|
||||
[InlineData("example&.com")] // ampersand
|
||||
[InlineData("example'.com")] // single quote
|
||||
[InlineData("example\".com")] // double quote
|
||||
[InlineData(".example.com")] // starts with dot
|
||||
[InlineData("example.com.")] // ends with dot
|
||||
[InlineData("example..com")] // double dot
|
||||
[InlineData("-example.com")] // starts with dash
|
||||
[InlineData("example-.com")] // label ends with dash
|
||||
[InlineData("")] // empty string
|
||||
[InlineData(" ")] // whitespace only
|
||||
[InlineData("http://example.com")] // URL scheme
|
||||
[InlineData("example.com/path")] // path component
|
||||
[InlineData("user@example.com")] // email format
|
||||
public void IsValid_ReturnsFalseWhenInvalid(string domainName)
|
||||
{
|
||||
var sut = new DomainNameValidatorAttribute();
|
||||
|
||||
var actual = sut.IsValid(domainName);
|
||||
|
||||
Assert.False(actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValid_ReturnsTrueWhenNull()
|
||||
{
|
||||
var sut = new DomainNameValidatorAttribute();
|
||||
|
||||
var actual = sut.IsValid(null);
|
||||
|
||||
// Null validation should be handled by [Required] attribute
|
||||
Assert.True(actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValid_ReturnsFalseWhenTooLong()
|
||||
{
|
||||
var sut = new DomainNameValidatorAttribute();
|
||||
// Create a domain name longer than 253 characters
|
||||
var longDomain = new string('a', 250) + ".com";
|
||||
|
||||
var actual = sut.IsValid(longDomain);
|
||||
|
||||
Assert.False(actual);
|
||||
}
|
||||
}
|
||||
219
test/Core.Test/Utilities/EnumMemberJsonConverterTests.cs
Normal file
219
test/Core.Test/Utilities/EnumMemberJsonConverterTests.cs
Normal file
@@ -0,0 +1,219 @@
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Utilities;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Utilities;
|
||||
|
||||
public class EnumMemberJsonConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Serialize_WithEnumMemberAttribute_UsesAttributeValue()
|
||||
{
|
||||
// Arrange
|
||||
var obj = new EnumConverterTestObject
|
||||
{
|
||||
Status = EnumConverterTestStatus.InProgress
|
||||
};
|
||||
const string expectedJsonString = "{\"Status\":\"in_progress\"}";
|
||||
|
||||
// Act
|
||||
var jsonString = JsonSerializer.Serialize(obj);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedJsonString, jsonString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_WithoutEnumMemberAttribute_UsesEnumName()
|
||||
{
|
||||
// Arrange
|
||||
var obj = new EnumConverterTestObject
|
||||
{
|
||||
Status = EnumConverterTestStatus.Pending
|
||||
};
|
||||
const string expectedJsonString = "{\"Status\":\"Pending\"}";
|
||||
|
||||
// Act
|
||||
var jsonString = JsonSerializer.Serialize(obj);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedJsonString, jsonString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_MultipleValues_SerializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var obj = new EnumConverterTestObjectWithMultiple
|
||||
{
|
||||
Status1 = EnumConverterTestStatus.Active,
|
||||
Status2 = EnumConverterTestStatus.InProgress,
|
||||
Status3 = EnumConverterTestStatus.Pending
|
||||
};
|
||||
const string expectedJsonString = "{\"Status1\":\"active\",\"Status2\":\"in_progress\",\"Status3\":\"Pending\"}";
|
||||
|
||||
// Act
|
||||
var jsonString = JsonSerializer.Serialize(obj);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedJsonString, jsonString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_WithEnumMemberAttribute_ReturnsCorrectEnumValue()
|
||||
{
|
||||
// Arrange
|
||||
const string json = "{\"Status\":\"in_progress\"}";
|
||||
|
||||
// Act
|
||||
var obj = JsonSerializer.Deserialize<EnumConverterTestObject>(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(EnumConverterTestStatus.InProgress, obj.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_WithoutEnumMemberAttribute_ReturnsCorrectEnumValue()
|
||||
{
|
||||
// Arrange
|
||||
const string json = "{\"Status\":\"Pending\"}";
|
||||
|
||||
// Act
|
||||
var obj = JsonSerializer.Deserialize<EnumConverterTestObject>(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(EnumConverterTestStatus.Pending, obj.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_MultipleValues_DeserializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
const string json = "{\"Status1\":\"active\",\"Status2\":\"in_progress\",\"Status3\":\"Pending\"}";
|
||||
|
||||
// Act
|
||||
var obj = JsonSerializer.Deserialize<EnumConverterTestObjectWithMultiple>(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(EnumConverterTestStatus.Active, obj.Status1);
|
||||
Assert.Equal(EnumConverterTestStatus.InProgress, obj.Status2);
|
||||
Assert.Equal(EnumConverterTestStatus.Pending, obj.Status3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_InvalidEnumString_ThrowsJsonException()
|
||||
{
|
||||
// Arrange
|
||||
const string json = "{\"Status\":\"invalid_value\"}";
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<EnumConverterTestObject>(json));
|
||||
Assert.Contains("Unable to convert 'invalid_value' to EnumConverterTestStatus", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_EmptyString_ThrowsJsonException()
|
||||
{
|
||||
// Arrange
|
||||
const string json = "{\"Status\":\"\"}";
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<EnumConverterTestObject>(json));
|
||||
Assert.Contains("Unable to convert '' to EnumConverterTestStatus", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_WithEnumMemberAttribute_PreservesValue()
|
||||
{
|
||||
// Arrange
|
||||
var originalObj = new EnumConverterTestObject
|
||||
{
|
||||
Status = EnumConverterTestStatus.Completed
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(originalObj);
|
||||
var deserializedObj = JsonSerializer.Deserialize<EnumConverterTestObject>(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(originalObj.Status, deserializedObj.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_WithoutEnumMemberAttribute_PreservesValue()
|
||||
{
|
||||
// Arrange
|
||||
var originalObj = new EnumConverterTestObject
|
||||
{
|
||||
Status = EnumConverterTestStatus.Pending
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(originalObj);
|
||||
var deserializedObj = JsonSerializer.Deserialize<EnumConverterTestObject>(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(originalObj.Status, deserializedObj.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_AllEnumValues_ProducesExpectedStrings()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.Equal("\"Pending\"", JsonSerializer.Serialize(EnumConverterTestStatus.Pending, CreateOptions()));
|
||||
Assert.Equal("\"active\"", JsonSerializer.Serialize(EnumConverterTestStatus.Active, CreateOptions()));
|
||||
Assert.Equal("\"in_progress\"", JsonSerializer.Serialize(EnumConverterTestStatus.InProgress, CreateOptions()));
|
||||
Assert.Equal("\"completed\"", JsonSerializer.Serialize(EnumConverterTestStatus.Completed, CreateOptions()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_AllEnumValues_ReturnsCorrectEnums()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.Equal(EnumConverterTestStatus.Pending, JsonSerializer.Deserialize<EnumConverterTestStatus>("\"Pending\"", CreateOptions()));
|
||||
Assert.Equal(EnumConverterTestStatus.Active, JsonSerializer.Deserialize<EnumConverterTestStatus>("\"active\"", CreateOptions()));
|
||||
Assert.Equal(EnumConverterTestStatus.InProgress, JsonSerializer.Deserialize<EnumConverterTestStatus>("\"in_progress\"", CreateOptions()));
|
||||
Assert.Equal(EnumConverterTestStatus.Completed, JsonSerializer.Deserialize<EnumConverterTestStatus>("\"completed\"", CreateOptions()));
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions();
|
||||
options.Converters.Add(new EnumMemberJsonConverter<EnumConverterTestStatus>());
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
public class EnumConverterTestObject
|
||||
{
|
||||
[JsonConverter(typeof(EnumMemberJsonConverter<EnumConverterTestStatus>))]
|
||||
public EnumConverterTestStatus Status { get; set; }
|
||||
}
|
||||
|
||||
public class EnumConverterTestObjectWithMultiple
|
||||
{
|
||||
[JsonConverter(typeof(EnumMemberJsonConverter<EnumConverterTestStatus>))]
|
||||
public EnumConverterTestStatus Status1 { get; set; }
|
||||
|
||||
[JsonConverter(typeof(EnumMemberJsonConverter<EnumConverterTestStatus>))]
|
||||
public EnumConverterTestStatus Status2 { get; set; }
|
||||
|
||||
[JsonConverter(typeof(EnumMemberJsonConverter<EnumConverterTestStatus>))]
|
||||
public EnumConverterTestStatus Status3 { get; set; }
|
||||
}
|
||||
|
||||
public enum EnumConverterTestStatus
|
||||
{
|
||||
Pending, // No EnumMemberAttribute
|
||||
|
||||
[EnumMember(Value = "active")]
|
||||
Active,
|
||||
|
||||
[EnumMember(Value = "in_progress")]
|
||||
InProgress,
|
||||
|
||||
[EnumMember(Value = "completed")]
|
||||
Completed
|
||||
}
|
||||
@@ -74,8 +74,7 @@ public class LoggerFactoryExtensionsTests
|
||||
|
||||
logger.LogWarning("This is a test");
|
||||
|
||||
// Writing to the file is buffered, give it a little time to flush
|
||||
await Task.Delay(5);
|
||||
await provider.DisposeAsync();
|
||||
|
||||
var logFile = Assert.Single(tempDir.EnumerateFiles("Logs/*.log"));
|
||||
|
||||
@@ -90,13 +89,67 @@ public class LoggerFactoryExtensionsTests
|
||||
logFileContents
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSerilogFileLogging_LegacyConfig_WithLevelCustomization_InfoLogs_DoNotFillUpFile()
|
||||
{
|
||||
await AssertSmallFileAsync((tempDir, config) =>
|
||||
{
|
||||
config["GlobalSettings:LogDirectory"] = tempDir;
|
||||
config["Logging:LogLevel:Microsoft.AspNetCore"] = "Warning";
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSerilogFileLogging_NewConfig_WithLevelCustomization_InfoLogs_DoNotFillUpFile()
|
||||
{
|
||||
await AssertSmallFileAsync((tempDir, config) =>
|
||||
{
|
||||
config["Logging:PathFormat"] = Path.Combine(tempDir, "log.txt");
|
||||
config["Logging:LogLevel:Microsoft.AspNetCore"] = "Warning";
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task AssertSmallFileAsync(Action<string, Dictionary<string, string?>> configure)
|
||||
{
|
||||
using var tempDir = new TempDirectory();
|
||||
var config = new Dictionary<string, string?>();
|
||||
|
||||
configure(tempDir.Directory, config);
|
||||
|
||||
var provider = GetServiceProvider(config, "Production");
|
||||
|
||||
var loggerFactory = provider.GetRequiredService<ILoggerFactory>();
|
||||
var microsoftLogger = loggerFactory.CreateLogger("Microsoft.AspNetCore.Testing");
|
||||
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
microsoftLogger.LogInformation("Tons of useless information");
|
||||
}
|
||||
|
||||
var otherLogger = loggerFactory.CreateLogger("Bitwarden");
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
otherLogger.LogInformation("Mildly more useful information but not as frequent.");
|
||||
}
|
||||
|
||||
await provider.DisposeAsync();
|
||||
|
||||
var logFiles = Directory.EnumerateFiles(tempDir.Directory, "*.txt", SearchOption.AllDirectories);
|
||||
var logFile = Assert.Single(logFiles);
|
||||
|
||||
using var fr = File.OpenRead(logFile);
|
||||
Assert.InRange(fr.Length, 0, 1024);
|
||||
}
|
||||
|
||||
private static IEnumerable<ILoggerProvider> GetProviders(Dictionary<string, string?> initialData, string environment = "Production")
|
||||
{
|
||||
var provider = GetServiceProvider(initialData, environment);
|
||||
return provider.GetServices<ILoggerProvider>();
|
||||
}
|
||||
|
||||
private static IServiceProvider GetServiceProvider(Dictionary<string, string?> initialData, string environment)
|
||||
private static ServiceProvider GetServiceProvider(Dictionary<string, string?> initialData, string environment)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(initialData)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Identity.IntegrationTest' " />
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -18,6 +18,7 @@ using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Identity.IdentityServer;
|
||||
using Bit.Identity.IdentityServer.RequestValidationConstants;
|
||||
using Bit.Identity.IdentityServer.RequestValidators;
|
||||
using Bit.Identity.Test.Wrappers;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
@@ -130,7 +131,7 @@ public class BaseRequestValidatorTests
|
||||
var logs = _logger.Collector.GetSnapshot(true);
|
||||
Assert.Contains(logs,
|
||||
l => l.Level == LogLevel.Warning && l.Message == "Failed login attempt. Is2FARequest: False IpAddress: ");
|
||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse[CustomResponseConstants.ResponseKeys.ErrorModel];
|
||||
Assert.Equal("Username or password is incorrect. Try again.", errorResponse.Message);
|
||||
}
|
||||
|
||||
@@ -161,7 +162,11 @@ public class BaseRequestValidatorTests
|
||||
.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(false));
|
||||
|
||||
// 5 -> not legacy user
|
||||
// 5 -> SSO not required
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// 6 -> not legacy user
|
||||
_userService.IsLegacyUser(Arg.Any<string>())
|
||||
.Returns(false);
|
||||
|
||||
@@ -203,6 +208,11 @@ public class BaseRequestValidatorTests
|
||||
_userService.IsLegacyUser(Arg.Any<string>())
|
||||
.Returns(false);
|
||||
|
||||
// 6 -> SSO validation passes
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// 7 -> setup user account keys
|
||||
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
|
||||
{
|
||||
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
|
||||
@@ -262,6 +272,11 @@ public class BaseRequestValidatorTests
|
||||
_userService.IsLegacyUser(Arg.Any<string>())
|
||||
.Returns(false);
|
||||
|
||||
// 6 -> SSO validation passes
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// 7 -> setup user account keys
|
||||
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
|
||||
{
|
||||
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
|
||||
@@ -326,6 +341,9 @@ public class BaseRequestValidatorTests
|
||||
{ "TwoFactorProviders2", new Dictionary<string, object> { { "Email", null } } }
|
||||
}));
|
||||
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
@@ -368,6 +386,10 @@ public class BaseRequestValidatorTests
|
||||
.VerifyTwoFactorAsync(user, null, TwoFactorProviderType.Email, "invalid_token")
|
||||
.Returns(Task.FromResult(false));
|
||||
|
||||
// 5 -> set up SSO required verification to succeed
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
@@ -396,21 +418,25 @@ public class BaseRequestValidatorTests
|
||||
// 1 -> initial validation passes
|
||||
_sut.isValid = true;
|
||||
|
||||
// 2 -> set up 2FA as required
|
||||
// 2 -> set up SSO required verification to succeed
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// 3 -> set up 2FA as required
|
||||
_twoFactorAuthenticationValidator
|
||||
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest)
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(true, null)));
|
||||
|
||||
// 3 -> provide invalid remember token (remember token expired)
|
||||
// 4 -> provide invalid remember token (remember token expired)
|
||||
tokenRequest.Raw["TwoFactorToken"] = "expired_remember_token";
|
||||
tokenRequest.Raw["TwoFactorProvider"] = "5"; // Remember provider
|
||||
|
||||
// 4 -> set up remember token verification to fail
|
||||
// 5 -> set up remember token verification to fail
|
||||
_twoFactorAuthenticationValidator
|
||||
.VerifyTwoFactorAsync(user, null, TwoFactorProviderType.Remember, "expired_remember_token")
|
||||
.Returns(Task.FromResult(false));
|
||||
|
||||
// 5 -> set up dummy BuildTwoFactorResultAsync
|
||||
// 6 -> set up dummy BuildTwoFactorResultAsync
|
||||
var twoFactorResultDict = new Dictionary<string, object>
|
||||
{
|
||||
{ "TwoFactorProviders", new[] { "0", "1" } },
|
||||
@@ -446,6 +472,19 @@ public class BaseRequestValidatorTests
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
|
||||
// SsoRequestValidator sets custom response
|
||||
requestContext.ValidationErrorResult = new ValidationResult
|
||||
{
|
||||
IsError = true,
|
||||
Error = SsoConstants.RequestErrors.SsoRequired,
|
||||
ErrorDescription = SsoConstants.RequestErrors.SsoRequiredDescription
|
||||
};
|
||||
requestContext.CustomResponse = new Dictionary<string, object>
|
||||
{
|
||||
{ CustomResponseConstants.ResponseKeys.ErrorModel, new ErrorResponseModel(SsoConstants.RequestErrors.SsoRequiredDescription) },
|
||||
};
|
||||
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
_sut.isValid = true;
|
||||
|
||||
@@ -454,13 +493,17 @@ public class BaseRequestValidatorTests
|
||||
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(false));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(context.GrantResult.IsError);
|
||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
||||
Assert.Equal("SSO authentication is required.", errorResponse.Message);
|
||||
Assert.NotNull(context.GrantResult.CustomResponse);
|
||||
var errorResponse = (ErrorResponseModel)context.CustomValidatorRequestContext.CustomResponse[CustomResponseConstants.ResponseKeys.ErrorModel];
|
||||
Assert.Equal(SsoConstants.RequestErrors.SsoRequiredDescription, errorResponse.Message);
|
||||
}
|
||||
|
||||
// Test grantTypes with RequireSsoPolicyRequirement when feature flag is enabled
|
||||
@@ -477,6 +520,20 @@ public class BaseRequestValidatorTests
|
||||
{
|
||||
// Arrange
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
|
||||
|
||||
// SsoRequestValidator sets custom response with organization identifier
|
||||
requestContext.ValidationErrorResult = new ValidationResult
|
||||
{
|
||||
IsError = true,
|
||||
Error = SsoConstants.RequestErrors.SsoRequired,
|
||||
ErrorDescription = SsoConstants.RequestErrors.SsoRequiredDescription
|
||||
};
|
||||
requestContext.CustomResponse = new Dictionary<string, object>
|
||||
{
|
||||
{ CustomResponseConstants.ResponseKeys.ErrorModel, new ErrorResponseModel(SsoConstants.RequestErrors.SsoRequiredDescription) },
|
||||
{ CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier, "test-org-identifier" }
|
||||
};
|
||||
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
_sut.isValid = true;
|
||||
|
||||
@@ -485,6 +542,10 @@ public class BaseRequestValidatorTests
|
||||
var requirement = new RequireSsoPolicyRequirement { SsoRequired = true };
|
||||
_policyRequirementQuery.GetAsync<RequireSsoPolicyRequirement>(Arg.Any<Guid>()).Returns(requirement);
|
||||
|
||||
// Mock the SSO validator to return false
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(false));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
@@ -492,8 +553,9 @@ public class BaseRequestValidatorTests
|
||||
await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
|
||||
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
||||
Assert.True(context.GrantResult.IsError);
|
||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
||||
Assert.Equal("SSO authentication is required.", errorResponse.Message);
|
||||
Assert.NotNull(context.GrantResult.CustomResponse);
|
||||
var errorResponse = (ErrorResponseModel)context.CustomValidatorRequestContext.CustomResponse[CustomResponseConstants.ResponseKeys.ErrorModel];
|
||||
Assert.Equal(SsoConstants.RequestErrors.SsoRequiredDescription, errorResponse.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -519,6 +581,10 @@ public class BaseRequestValidatorTests
|
||||
var requirement = new RequireSsoPolicyRequirement { SsoRequired = false };
|
||||
_policyRequirementQuery.GetAsync<RequireSsoPolicyRequirement>(Arg.Any<Guid>()).Returns(requirement);
|
||||
|
||||
// SSO validation passes
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
@@ -561,6 +627,11 @@ public class BaseRequestValidatorTests
|
||||
_policyService.AnyPoliciesApplicableToUserAsync(
|
||||
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
|
||||
.Returns(Task.FromResult(false));
|
||||
|
||||
// SSO validation passes
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
@@ -603,6 +674,10 @@ public class BaseRequestValidatorTests
|
||||
|
||||
context.ValidatedTokenRequest.GrantType = grantType;
|
||||
|
||||
// SSO validation passes
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
@@ -652,13 +727,15 @@ public class BaseRequestValidatorTests
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(context.GrantResult.IsError);
|
||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse[CustomResponseConstants.ResponseKeys.ErrorModel];
|
||||
var expectedMessage =
|
||||
"Legacy encryption without a userkey is no longer supported. To recover your account, please contact support";
|
||||
Assert.Equal(expectedMessage, errorResponse.Message);
|
||||
@@ -694,6 +771,10 @@ public class BaseRequestValidatorTests
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
_sut.isValid = true;
|
||||
|
||||
// SSO validation passes
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
@@ -760,6 +841,8 @@ public class BaseRequestValidatorTests
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
@@ -833,6 +916,8 @@ public class BaseRequestValidatorTests
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
@@ -877,6 +962,8 @@ public class BaseRequestValidatorTests
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
@@ -921,6 +1008,8 @@ public class BaseRequestValidatorTests
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
@@ -950,6 +1039,19 @@ public class BaseRequestValidatorTests
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
|
||||
// SsoRequestValidator sets custom response
|
||||
requestContext.ValidationErrorResult = new ValidationResult
|
||||
{
|
||||
IsError = true,
|
||||
Error = SsoConstants.RequestErrors.SsoRequired,
|
||||
ErrorDescription = SsoConstants.RequestErrors.SsoRequiredDescription
|
||||
};
|
||||
requestContext.CustomResponse = new Dictionary<string, object>
|
||||
{
|
||||
{ CustomResponseConstants.ResponseKeys.ErrorModel, new ErrorResponseModel(SsoConstants.RequestErrors.SsoRequiredDescription) },
|
||||
};
|
||||
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
var user = requestContext.User;
|
||||
|
||||
@@ -984,12 +1086,12 @@ public class BaseRequestValidatorTests
|
||||
|
||||
// Assert
|
||||
Assert.True(context.GrantResult.IsError, "Authentication should fail - SSO required after recovery");
|
||||
|
||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
||||
Assert.NotNull(context.GrantResult.CustomResponse);
|
||||
var errorResponse = (ErrorResponseModel)context.CustomValidatorRequestContext.CustomResponse[CustomResponseConstants.ResponseKeys.ErrorModel];
|
||||
|
||||
// Recovery succeeds, then SSO blocks with descriptive message
|
||||
Assert.Equal(
|
||||
"Two-factor recovery has been performed. SSO authentication is required.",
|
||||
SsoConstants.RequestErrors.SsoRequiredDescription,
|
||||
errorResponse.Message);
|
||||
|
||||
// Verify recovery was marked
|
||||
@@ -1050,7 +1152,7 @@ public class BaseRequestValidatorTests
|
||||
// Assert
|
||||
Assert.True(context.GrantResult.IsError, "Authentication should fail - invalid recovery code");
|
||||
|
||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse[CustomResponseConstants.ResponseKeys.ErrorModel];
|
||||
|
||||
// 2FA is checked first (due to recovery code request), fails with 2FA error
|
||||
Assert.Equal(
|
||||
@@ -1132,7 +1234,11 @@ public class BaseRequestValidatorTests
|
||||
_userService.IsLegacyUser(Arg.Any<string>())
|
||||
.Returns(false);
|
||||
|
||||
// 8. Setup user account keys for successful login response
|
||||
// 8. SSO is not required
|
||||
_ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// 9. Setup user account keys for successful login response
|
||||
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
|
||||
{
|
||||
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
|
||||
@@ -1161,179 +1267,18 @@ public class BaseRequestValidatorTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when RedirectOnSsoRequired is DISABLED, the legacy SSO validation path is used.
|
||||
/// This validates the deprecated RequireSsoLoginAsync method is called and SSO requirement
|
||||
/// is checked using the old PolicyService.AnyPoliciesApplicableToUserAsync approach.
|
||||
/// Tests that when SSO validation returns a custom response, (e.g., with organization identifier),
|
||||
/// that custom response is properly propagated to the result.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_RedirectOnSsoRequired_Disabled_UsesLegacySsoValidation(
|
||||
public async Task ValidateAsync_SsoRequired_PropagatesCustomResponse(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
[AuthFixtures.CustomValidatorRequestContext]
|
||||
CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(false);
|
||||
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
_sut.isValid = true;
|
||||
|
||||
tokenRequest.GrantType = OidcConstants.GrantTypes.Password;
|
||||
|
||||
// SSO is required via legacy path
|
||||
_policyService.AnyPoliciesApplicableToUserAsync(
|
||||
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(context.GrantResult.IsError);
|
||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
||||
Assert.Equal("SSO authentication is required.", errorResponse.Message);
|
||||
|
||||
// Verify legacy path was used
|
||||
await _policyService.Received(1).AnyPoliciesApplicableToUserAsync(
|
||||
requestContext.User.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
||||
|
||||
// Verify new SsoRequestValidator was NOT called
|
||||
await _ssoRequestValidator.DidNotReceive().ValidateAsync(
|
||||
Arg.Any<User>(), Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when RedirectOnSsoRequired is ENABLED, the new ISsoRequestValidator is used
|
||||
/// instead of the legacy RequireSsoLoginAsync method.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_UsesNewSsoRequestValidator(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
[AuthFixtures.CustomValidatorRequestContext]
|
||||
CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true);
|
||||
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
_sut.isValid = true;
|
||||
|
||||
tokenRequest.GrantType = OidcConstants.GrantTypes.Password;
|
||||
|
||||
// Configure SsoRequestValidator to indicate SSO is required
|
||||
_ssoRequestValidator.ValidateAsync(
|
||||
Arg.Any<User>(),
|
||||
Arg.Any<ValidatedTokenRequest>(),
|
||||
Arg.Any<CustomValidatorRequestContext>())
|
||||
.Returns(Task.FromResult(false)); // false = SSO required
|
||||
|
||||
// Set up the ValidationErrorResult that SsoRequestValidator would set
|
||||
requestContext.ValidationErrorResult = new ValidationResult
|
||||
{
|
||||
IsError = true,
|
||||
Error = "sso_required",
|
||||
ErrorDescription = "SSO authentication is required."
|
||||
};
|
||||
requestContext.CustomResponse = new Dictionary<string, object>
|
||||
{
|
||||
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
|
||||
};
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(context.GrantResult.IsError);
|
||||
|
||||
// Verify new SsoRequestValidator was called
|
||||
await _ssoRequestValidator.Received(1).ValidateAsync(
|
||||
requestContext.User,
|
||||
tokenRequest,
|
||||
requestContext);
|
||||
|
||||
// Verify legacy path was NOT used
|
||||
await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
|
||||
Arg.Any<Guid>(), Arg.Any<PolicyType>(), Arg.Any<OrganizationUserStatusType>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when RedirectOnSsoRequired is ENABLED and SSO is NOT required,
|
||||
/// authentication continues successfully through the new validation path.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_SsoNotRequired_SuccessfulLogin(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
[AuthFixtures.CustomValidatorRequestContext]
|
||||
CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true);
|
||||
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
_sut.isValid = true;
|
||||
|
||||
tokenRequest.GrantType = OidcConstants.GrantTypes.Password;
|
||||
tokenRequest.ClientId = "web";
|
||||
|
||||
// SsoRequestValidator returns true (SSO not required)
|
||||
_ssoRequestValidator.ValidateAsync(
|
||||
Arg.Any<User>(),
|
||||
Arg.Any<ValidatedTokenRequest>(),
|
||||
Arg.Any<CustomValidatorRequestContext>())
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// No 2FA required
|
||||
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
|
||||
|
||||
// Device validation passes
|
||||
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// User is not legacy
|
||||
_userService.IsLegacyUser(Arg.Any<string>()).Returns(false);
|
||||
|
||||
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
|
||||
{
|
||||
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
|
||||
"test-private-key",
|
||||
"test-public-key"
|
||||
)
|
||||
});
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.False(context.GrantResult.IsError);
|
||||
await _eventService.Received(1).LogUserEventAsync(requestContext.User.Id, EventType.User_LoggedIn);
|
||||
|
||||
// Verify new validator was used
|
||||
await _ssoRequestValidator.Received(1).ValidateAsync(
|
||||
requestContext.User,
|
||||
tokenRequest,
|
||||
requestContext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when RedirectOnSsoRequired is ENABLED and SSO validation returns a custom response
|
||||
/// (e.g., with organization identifier), that custom response is properly propagated to the result.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_PropagatesCustomResponse(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
[AuthFixtures.CustomValidatorRequestContext]
|
||||
CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true);
|
||||
_sut.isValid = true;
|
||||
|
||||
tokenRequest.GrantType = OidcConstants.GrantTypes.Password;
|
||||
@@ -1342,13 +1287,13 @@ public class BaseRequestValidatorTests
|
||||
requestContext.ValidationErrorResult = new ValidationResult
|
||||
{
|
||||
IsError = true,
|
||||
Error = "sso_required",
|
||||
ErrorDescription = "SSO authentication is required."
|
||||
Error = SsoConstants.RequestErrors.SsoRequired,
|
||||
ErrorDescription = SsoConstants.RequestErrors.SsoRequiredDescription
|
||||
};
|
||||
requestContext.CustomResponse = new Dictionary<string, object>
|
||||
{
|
||||
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") },
|
||||
{ "SsoOrganizationIdentifier", "test-org-identifier" }
|
||||
{ CustomResponseConstants.ResponseKeys.ErrorModel, new ErrorResponseModel(SsoConstants.RequestErrors.SsoRequiredDescription) },
|
||||
{ CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier, "test-org-identifier" }
|
||||
};
|
||||
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
@@ -1365,77 +1310,24 @@ public class BaseRequestValidatorTests
|
||||
// Assert
|
||||
Assert.True(context.GrantResult.IsError);
|
||||
Assert.NotNull(context.GrantResult.CustomResponse);
|
||||
Assert.Contains("SsoOrganizationIdentifier", context.CustomValidatorRequestContext.CustomResponse);
|
||||
Assert.Contains(CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier, context.CustomValidatorRequestContext.CustomResponse);
|
||||
Assert.Equal("test-org-identifier",
|
||||
context.CustomValidatorRequestContext.CustomResponse["SsoOrganizationIdentifier"]);
|
||||
context.CustomValidatorRequestContext.CustomResponse[CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when RedirectOnSsoRequired is DISABLED and a user with 2FA recovery completes recovery,
|
||||
/// but SSO is required, the legacy error message is returned (without the recovery-specific message).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_RedirectOnSsoRequired_Disabled_RecoveryWithSso_LegacyMessage(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
[AuthFixtures.CustomValidatorRequestContext]
|
||||
CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(false);
|
||||
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
_sut.isValid = true;
|
||||
|
||||
// Recovery code scenario
|
||||
tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString();
|
||||
tokenRequest.Raw["TwoFactorToken"] = "valid-recovery-code";
|
||||
|
||||
// 2FA with recovery
|
||||
_twoFactorAuthenticationValidator
|
||||
.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
|
||||
.Returns(Task.FromResult(new Tuple<bool, Organization>(true, null)));
|
||||
|
||||
_twoFactorAuthenticationValidator
|
||||
.VerifyTwoFactorAsync(requestContext.User, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code")
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// SSO is required (legacy check)
|
||||
_policyService.AnyPoliciesApplicableToUserAsync(
|
||||
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(context.GrantResult.IsError);
|
||||
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
|
||||
|
||||
// Legacy behavior: recovery-specific message IS shown even without RedirectOnSsoRequired
|
||||
Assert.Equal("Two-factor recovery has been performed. SSO authentication is required.", errorResponse.Message);
|
||||
|
||||
// But legacy validation path was used
|
||||
await _policyService.Received(1).AnyPoliciesApplicableToUserAsync(
|
||||
requestContext.User.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when RedirectOnSsoRequired is ENABLED and recovery code is used for SSO-required user,
|
||||
/// Tests that when a recovery code is used for SSO-required user,
|
||||
/// the SsoRequestValidator provides the recovery-specific error message.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_RecoveryWithSso_NewValidatorMessage(
|
||||
public async Task ValidateAsync_RecoveryWithSso_CorrectValidatorMessage(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
[AuthFixtures.CustomValidatorRequestContext]
|
||||
CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true);
|
||||
|
||||
var context = CreateContext(tokenRequest, requestContext, grantResult);
|
||||
_sut.isValid = true;
|
||||
|
||||
@@ -1457,14 +1349,14 @@ public class BaseRequestValidatorTests
|
||||
requestContext.ValidationErrorResult = new ValidationResult
|
||||
{
|
||||
IsError = true,
|
||||
Error = "sso_required",
|
||||
ErrorDescription = "Two-factor recovery has been performed. SSO authentication is required."
|
||||
Error = SsoConstants.RequestErrors.SsoRequired,
|
||||
ErrorDescription = SsoConstants.RequestErrors.SsoTwoFactorRecoveryDescription
|
||||
};
|
||||
requestContext.CustomResponse = new Dictionary<string, object>
|
||||
{
|
||||
{
|
||||
"ErrorModel",
|
||||
new ErrorResponseModel("Two-factor recovery has been performed. SSO authentication is required.")
|
||||
CustomResponseConstants.ResponseKeys.ErrorModel,
|
||||
new ErrorResponseModel(SsoConstants.RequestErrors.SsoTwoFactorRecoveryDescription)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1479,18 +1371,8 @@ public class BaseRequestValidatorTests
|
||||
|
||||
// Assert
|
||||
Assert.True(context.GrantResult.IsError);
|
||||
var errorResponse = (ErrorResponseModel)context.CustomValidatorRequestContext.CustomResponse["ErrorModel"];
|
||||
Assert.Equal("Two-factor recovery has been performed. SSO authentication is required.", errorResponse.Message);
|
||||
|
||||
// Verify new validator was used
|
||||
await _ssoRequestValidator.Received(1).ValidateAsync(
|
||||
requestContext.User,
|
||||
tokenRequest,
|
||||
Arg.Is<CustomValidatorRequestContext>(ctx => ctx.TwoFactorRecoveryRequested));
|
||||
|
||||
// Verify legacy path was NOT used
|
||||
await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
|
||||
Arg.Any<Guid>(), Arg.Any<PolicyType>(), Arg.Any<OrganizationUserStatusType>());
|
||||
var errorResponse = (ErrorResponseModel)context.CustomValidatorRequestContext.CustomResponse[CustomResponseConstants.ResponseKeys.ErrorModel];
|
||||
Assert.Equal(SsoConstants.RequestErrors.SsoTwoFactorRecoveryDescription, errorResponse.Message);
|
||||
}
|
||||
|
||||
private BaseRequestValidationContextFake CreateContext(
|
||||
|
||||
@@ -111,15 +111,6 @@ IBaseRequestValidatorTestWrapper
|
||||
context.GrantResult = new GrantValidationResult(TokenRequestErrors.InvalidGrant, customResponse: customResponse);
|
||||
}
|
||||
|
||||
[Obsolete]
|
||||
protected override void SetSsoResult(
|
||||
BaseRequestValidationContextFake context,
|
||||
Dictionary<string, object> customResponse)
|
||||
{
|
||||
context.GrantResult = new GrantValidationResult(
|
||||
TokenRequestErrors.InvalidGrant, "Sso authentication required.", customResponse);
|
||||
}
|
||||
|
||||
protected override Task SetSuccessResult(
|
||||
BaseRequestValidationContextFake context,
|
||||
User user,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Test.AutoFixture.Attributes;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Infrastructure.EFIntegration.Test.AutoFixture;
|
||||
using Bit.Infrastructure.EFIntegration.Test.Repositories.EqualityComparers;
|
||||
@@ -279,4 +280,92 @@ public class CipherRepositoryTests
|
||||
Assert.Equal(Core.Vault.Enums.CipherRepromptType.Password, savedCipher.Reprompt);
|
||||
}
|
||||
}
|
||||
|
||||
[CiSkippedTheory, EfUserCipherCustomize, BitAutoData]
|
||||
public async Task ArchiveAsync_SetsArchivesJsonAndBumpsUserAccountRevisionDate(
|
||||
Cipher cipher,
|
||||
User user,
|
||||
List<EfVaultRepo.CipherRepository> suts,
|
||||
List<EfRepo.UserRepository> efUserRepos)
|
||||
{
|
||||
foreach (var sut in suts)
|
||||
{
|
||||
var i = suts.IndexOf(sut);
|
||||
|
||||
var efUser = await efUserRepos[i].CreateAsync(user);
|
||||
efUserRepos[i].ClearChangeTracking();
|
||||
|
||||
cipher.UserId = efUser.Id;
|
||||
cipher.OrganizationId = null;
|
||||
|
||||
var createdCipher = await sut.CreateAsync(cipher);
|
||||
sut.ClearChangeTracking();
|
||||
|
||||
var archiveUtcNow = await sut.ArchiveAsync(new[] { createdCipher.Id }, efUser.Id);
|
||||
sut.ClearChangeTracking();
|
||||
|
||||
var savedCipher = await sut.GetByIdAsync(createdCipher.Id);
|
||||
Assert.NotNull(savedCipher);
|
||||
|
||||
Assert.Equal(archiveUtcNow, savedCipher.RevisionDate);
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(savedCipher.Archives));
|
||||
var archives = CoreHelpers.LoadClassFromJsonData<Dictionary<Guid, DateTime>>(savedCipher.Archives);
|
||||
Assert.NotNull(archives);
|
||||
Assert.True(archives.ContainsKey(efUser.Id));
|
||||
Assert.Equal(archiveUtcNow, archives[efUser.Id]);
|
||||
|
||||
var bumpedUser = await efUserRepos[i].GetByIdAsync(efUser.Id);
|
||||
Assert.Equal(DateTime.UtcNow.ToShortDateString(), bumpedUser.AccountRevisionDate.ToShortDateString());
|
||||
}
|
||||
}
|
||||
|
||||
[CiSkippedTheory, EfUserCipherCustomize, BitAutoData]
|
||||
public async Task UnarchiveAsync_RemovesUserFromArchivesJsonAndBumpsUserAccountRevisionDate(
|
||||
Cipher cipher,
|
||||
User user,
|
||||
List<EfVaultRepo.CipherRepository> suts,
|
||||
List<EfRepo.UserRepository> efUserRepos)
|
||||
{
|
||||
foreach (var sut in suts)
|
||||
{
|
||||
var i = suts.IndexOf(sut);
|
||||
|
||||
var efUser = await efUserRepos[i].CreateAsync(user);
|
||||
efUserRepos[i].ClearChangeTracking();
|
||||
|
||||
cipher.UserId = efUser.Id;
|
||||
cipher.OrganizationId = null;
|
||||
|
||||
var createdCipher = await sut.CreateAsync(cipher);
|
||||
sut.ClearChangeTracking();
|
||||
|
||||
// Precondition: archived
|
||||
await sut.ArchiveAsync(new[] { createdCipher.Id }, efUser.Id);
|
||||
sut.ClearChangeTracking();
|
||||
|
||||
var unarchiveUtcNow = await sut.UnarchiveAsync(new[] { createdCipher.Id }, efUser.Id);
|
||||
sut.ClearChangeTracking();
|
||||
|
||||
var savedCipher = await sut.GetByIdAsync(createdCipher.Id);
|
||||
Assert.NotNull(savedCipher);
|
||||
|
||||
Assert.Equal(unarchiveUtcNow, savedCipher.RevisionDate);
|
||||
|
||||
// Archives should be null or not contain this user (repo clears string when map empty)
|
||||
if (!string.IsNullOrWhiteSpace(savedCipher.Archives))
|
||||
{
|
||||
var archives = CoreHelpers.LoadClassFromJsonData<Dictionary<Guid, DateTime>>(savedCipher.Archives)
|
||||
?? new Dictionary<Guid, DateTime>();
|
||||
Assert.False(archives.ContainsKey(efUser.Id));
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Null(savedCipher.Archives);
|
||||
}
|
||||
|
||||
var bumpedUser = await efUserRepos[i].GetByIdAsync(efUser.Id);
|
||||
Assert.Equal(DateTime.UtcNow.ToShortDateString(), bumpedUser.AccountRevisionDate.ToShortDateString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using Bit.Core.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository;
|
||||
|
||||
|
||||
public class CreateDefaultCollectionsBulkAsyncTests
|
||||
{
|
||||
[Theory, DatabaseData]
|
||||
public async Task CreateDefaultCollectionsBulkAsync_CreatesDefaultCollections_Success(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
ICollectionRepository collectionRepository)
|
||||
{
|
||||
await CreateDefaultCollectionsSharedTests.CreatesDefaultCollections_Success(
|
||||
collectionRepository.CreateDefaultCollectionsBulkAsync,
|
||||
organizationRepository,
|
||||
userRepository,
|
||||
organizationUserRepository,
|
||||
collectionRepository);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task CreateDefaultCollectionsBulkAsync_CreatesForNewUsersOnly_AndIgnoresExistingUsers(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
ICollectionRepository collectionRepository)
|
||||
{
|
||||
await CreateDefaultCollectionsSharedTests.CreatesForNewUsersOnly_AndIgnoresExistingUsers(
|
||||
collectionRepository.CreateDefaultCollectionsBulkAsync,
|
||||
organizationRepository,
|
||||
userRepository,
|
||||
organizationUserRepository,
|
||||
collectionRepository);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task CreateDefaultCollectionsBulkAsync_IgnoresAllExistingUsers(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
ICollectionRepository collectionRepository)
|
||||
{
|
||||
await CreateDefaultCollectionsSharedTests.IgnoresAllExistingUsers(
|
||||
collectionRepository.CreateDefaultCollectionsBulkAsync,
|
||||
organizationRepository,
|
||||
userRepository,
|
||||
organizationUserRepository,
|
||||
collectionRepository);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,14 @@ using Xunit;
|
||||
|
||||
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository;
|
||||
|
||||
public class UpsertDefaultCollectionsTests
|
||||
/// <summary>
|
||||
/// Shared tests for CreateDefaultCollections methods - both bulk and non-bulk implementations,
|
||||
/// as they share the same behavior. Both test suites call the tests in this class.
|
||||
/// </summary>
|
||||
public static class CreateDefaultCollectionsSharedTests
|
||||
{
|
||||
[Theory, DatabaseData]
|
||||
public async Task UpsertDefaultCollectionsAsync_ShouldCreateDefaultCollection_WhenUsersDoNotHaveDefaultCollection(
|
||||
public static async Task CreatesDefaultCollections_Success(
|
||||
Func<Guid, IEnumerable<Guid>, string, Task> createDefaultCollectionsFunc,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@@ -21,14 +25,13 @@ public class UpsertDefaultCollectionsTests
|
||||
var resultOrganizationUsers = await Task.WhenAll(
|
||||
CreateUserForOrgAsync(userRepository, organizationUserRepository, organization),
|
||||
CreateUserForOrgAsync(userRepository, organizationUserRepository, organization)
|
||||
);
|
||||
);
|
||||
|
||||
|
||||
var affectedOrgUserIds = resultOrganizationUsers.Select(organizationUser => organizationUser.Id);
|
||||
var affectedOrgUserIds = resultOrganizationUsers.Select(organizationUser => organizationUser.Id).ToList();
|
||||
var defaultCollectionName = $"default-name-{organization.Id}";
|
||||
|
||||
// Act
|
||||
await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName);
|
||||
await createDefaultCollectionsFunc(organization.Id, affectedOrgUserIds, defaultCollectionName);
|
||||
|
||||
// Assert
|
||||
await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id);
|
||||
@@ -36,8 +39,8 @@ public class UpsertDefaultCollectionsTests
|
||||
await CleanupAsync(organizationRepository, userRepository, organization, resultOrganizationUsers);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task UpsertDefaultCollectionsAsync_ShouldUpsertCreateDefaultCollection_ForUsersWithAndWithoutDefaultCollectionsExist(
|
||||
public static async Task CreatesForNewUsersOnly_AndIgnoresExistingUsers(
|
||||
Func<Guid, IEnumerable<Guid>, string, Task> createDefaultCollectionsFunc,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@@ -51,31 +54,30 @@ public class UpsertDefaultCollectionsTests
|
||||
CreateUserForOrgAsync(userRepository, organizationUserRepository, organization)
|
||||
);
|
||||
|
||||
var arrangedOrgUserIds = arrangedOrganizationUsers.Select(organizationUser => organizationUser.Id);
|
||||
var arrangedOrgUserIds = arrangedOrganizationUsers.Select(organizationUser => organizationUser.Id).ToList();
|
||||
var defaultCollectionName = $"default-name-{organization.Id}";
|
||||
|
||||
await CreateUsersWithExistingDefaultCollectionsAsync(createDefaultCollectionsFunc, collectionRepository, organization.Id, arrangedOrgUserIds, defaultCollectionName, arrangedOrganizationUsers);
|
||||
|
||||
await CreateUsersWithExistingDefaultCollectionsAsync(collectionRepository, organization.Id, arrangedOrgUserIds, defaultCollectionName, arrangedOrganizationUsers);
|
||||
|
||||
var newOrganizationUsers = new List<OrganizationUser>()
|
||||
var newOrganizationUsers = new List<OrganizationUser>
|
||||
{
|
||||
await CreateUserForOrgAsync(userRepository, organizationUserRepository, organization)
|
||||
};
|
||||
|
||||
var affectedOrgUsers = newOrganizationUsers.Concat(arrangedOrganizationUsers);
|
||||
var affectedOrgUserIds = affectedOrgUsers.Select(organizationUser => organizationUser.Id);
|
||||
var affectedOrgUsers = newOrganizationUsers.Concat(arrangedOrganizationUsers).ToList();
|
||||
var affectedOrgUserIds = affectedOrgUsers.Select(organizationUser => organizationUser.Id).ToList();
|
||||
|
||||
// Act
|
||||
await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName);
|
||||
await createDefaultCollectionsFunc(organization.Id, affectedOrgUserIds, defaultCollectionName);
|
||||
|
||||
// Assert
|
||||
await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, arrangedOrganizationUsers, organization.Id);
|
||||
await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, affectedOrgUsers, organization.Id);
|
||||
|
||||
await CleanupAsync(organizationRepository, userRepository, organization, affectedOrgUsers);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task UpsertDefaultCollectionsAsync_ShouldNotCreateDefaultCollection_WhenUsersAlreadyHaveOne(
|
||||
public static async Task IgnoresAllExistingUsers(
|
||||
Func<Guid, IEnumerable<Guid>, string, Task> createDefaultCollectionsFunc,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@@ -89,26 +91,29 @@ public class UpsertDefaultCollectionsTests
|
||||
CreateUserForOrgAsync(userRepository, organizationUserRepository, organization)
|
||||
);
|
||||
|
||||
var affectedOrgUserIds = resultOrganizationUsers.Select(organizationUser => organizationUser.Id);
|
||||
var affectedOrgUserIds = resultOrganizationUsers.Select(organizationUser => organizationUser.Id).ToList();
|
||||
var defaultCollectionName = $"default-name-{organization.Id}";
|
||||
|
||||
await CreateUsersWithExistingDefaultCollectionsAsync(createDefaultCollectionsFunc, collectionRepository, organization.Id, affectedOrgUserIds, defaultCollectionName, resultOrganizationUsers);
|
||||
|
||||
await CreateUsersWithExistingDefaultCollectionsAsync(collectionRepository, organization.Id, affectedOrgUserIds, defaultCollectionName, resultOrganizationUsers);
|
||||
// Act - Try to create again, should silently filter and not create duplicates
|
||||
await createDefaultCollectionsFunc(organization.Id, affectedOrgUserIds, defaultCollectionName);
|
||||
|
||||
// Act
|
||||
await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName);
|
||||
|
||||
// Assert
|
||||
// Assert - Original collections should remain unchanged, still only one per user
|
||||
await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id);
|
||||
|
||||
await CleanupAsync(organizationRepository, userRepository, organization, resultOrganizationUsers);
|
||||
}
|
||||
|
||||
private static async Task CreateUsersWithExistingDefaultCollectionsAsync(ICollectionRepository collectionRepository,
|
||||
Guid organizationId, IEnumerable<Guid> affectedOrgUserIds, string defaultCollectionName,
|
||||
private static async Task CreateUsersWithExistingDefaultCollectionsAsync(
|
||||
Func<Guid, IEnumerable<Guid>, string, Task> createDefaultCollectionsFunc,
|
||||
ICollectionRepository collectionRepository,
|
||||
Guid organizationId,
|
||||
IEnumerable<Guid> affectedOrgUserIds,
|
||||
string defaultCollectionName,
|
||||
OrganizationUser[] resultOrganizationUsers)
|
||||
{
|
||||
await collectionRepository.UpsertDefaultCollectionsAsync(organizationId, affectedOrgUserIds, defaultCollectionName);
|
||||
await createDefaultCollectionsFunc(organizationId, affectedOrgUserIds, defaultCollectionName);
|
||||
|
||||
await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organizationId);
|
||||
}
|
||||
@@ -131,7 +136,6 @@ public class UpsertDefaultCollectionsTests
|
||||
private static async Task<OrganizationUser> CreateUserForOrgAsync(IUserRepository userRepository,
|
||||
IOrganizationUserRepository organizationUserRepository, Organization organization)
|
||||
{
|
||||
|
||||
var user = await userRepository.CreateTestUserAsync();
|
||||
var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using Bit.Core.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository;
|
||||
|
||||
public class CreateDefaultCollectionsAsyncTests
|
||||
{
|
||||
[Theory, DatabaseData]
|
||||
public async Task CreateDefaultCollectionsAsync_CreatesDefaultCollections_Success(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
ICollectionRepository collectionRepository)
|
||||
{
|
||||
await CreateDefaultCollectionsSharedTests.CreatesDefaultCollections_Success(
|
||||
collectionRepository.CreateDefaultCollectionsAsync,
|
||||
organizationRepository,
|
||||
userRepository,
|
||||
organizationUserRepository,
|
||||
collectionRepository);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task CreateDefaultCollectionsAsync_CreatesForNewUsersOnly_AndIgnoresExistingUsers(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
ICollectionRepository collectionRepository)
|
||||
{
|
||||
await CreateDefaultCollectionsSharedTests.CreatesForNewUsersOnly_AndIgnoresExistingUsers(
|
||||
collectionRepository.CreateDefaultCollectionsAsync,
|
||||
organizationRepository,
|
||||
userRepository,
|
||||
organizationUserRepository,
|
||||
collectionRepository);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task CreateDefaultCollectionsAsync_IgnoresAllExistingUsers(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
ICollectionRepository collectionRepository)
|
||||
{
|
||||
await CreateDefaultCollectionsSharedTests.IgnoresAllExistingUsers(
|
||||
collectionRepository.CreateDefaultCollectionsAsync,
|
||||
organizationRepository,
|
||||
userRepository,
|
||||
organizationUserRepository,
|
||||
collectionRepository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.OrganizationRepository;
|
||||
|
||||
public class GetByVerifiedUserEmailDomainAsyncTests
|
||||
{
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetByClaimedUserDomainAsync_WithVerifiedDomain_Success(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationDomainRepository organizationDomainRepository)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var domainName = $"{id}.example.com";
|
||||
|
||||
var user1 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User 1",
|
||||
Email = $"test+{id}@{domainName}",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var user2 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User 2",
|
||||
Email = $"test+{id}@x-{domainName}", // Different domain
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var user3 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User 2",
|
||||
Email = $"test+{id}@{domainName}.example.com", // Different domain
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateTestOrganizationAsync();
|
||||
|
||||
var organizationDomain = new OrganizationDomain
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
DomainName = domainName,
|
||||
Txt = "btw+12345",
|
||||
};
|
||||
organizationDomain.SetVerifiedDate();
|
||||
organizationDomain.SetNextRunDate(12);
|
||||
organizationDomain.SetJobRunCount();
|
||||
await organizationDomainRepository.CreateAsync(organizationDomain);
|
||||
|
||||
await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user1);
|
||||
await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user2);
|
||||
await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user3);
|
||||
|
||||
var user1Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user1.Id);
|
||||
var user2Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user2.Id);
|
||||
var user3Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user3.Id);
|
||||
|
||||
Assert.NotEmpty(user1Response);
|
||||
Assert.Equal(organization.Id, user1Response.First().Id);
|
||||
Assert.Empty(user2Response);
|
||||
Assert.Empty(user3Response);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetByVerifiedUserEmailDomainAsync_WithUnverifiedDomains_ReturnsEmpty(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationDomainRepository organizationDomainRepository)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var domainName = $"{id}.example.com";
|
||||
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"test+{id}@{domainName}",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateTestOrganizationAsync();
|
||||
|
||||
var organizationDomain = new OrganizationDomain
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
DomainName = domainName,
|
||||
Txt = "btw+12345",
|
||||
};
|
||||
organizationDomain.SetNextRunDate(12);
|
||||
organizationDomain.SetJobRunCount();
|
||||
await organizationDomainRepository.CreateAsync(organizationDomain);
|
||||
|
||||
await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user);
|
||||
|
||||
var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetByVerifiedUserEmailDomainAsync_WithMultipleVerifiedDomains_ReturnsAllMatchingOrganizations(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationDomainRepository organizationDomainRepository)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var domainName = $"{id}.example.com";
|
||||
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"test+{id}@{domainName}",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var organization1 = await organizationRepository.CreateTestOrganizationAsync();
|
||||
var organization2 = await organizationRepository.CreateTestOrganizationAsync();
|
||||
|
||||
var organizationDomain1 = new OrganizationDomain
|
||||
{
|
||||
OrganizationId = organization1.Id,
|
||||
DomainName = domainName,
|
||||
Txt = "btw+12345",
|
||||
};
|
||||
organizationDomain1.SetNextRunDate(12);
|
||||
organizationDomain1.SetJobRunCount();
|
||||
organizationDomain1.SetVerifiedDate();
|
||||
await organizationDomainRepository.CreateAsync(organizationDomain1);
|
||||
|
||||
var organizationDomain2 = new OrganizationDomain
|
||||
{
|
||||
OrganizationId = organization2.Id,
|
||||
DomainName = domainName,
|
||||
Txt = "btw+67890",
|
||||
};
|
||||
organizationDomain2.SetNextRunDate(12);
|
||||
organizationDomain2.SetJobRunCount();
|
||||
organizationDomain2.SetVerifiedDate();
|
||||
await organizationDomainRepository.CreateAsync(organizationDomain2);
|
||||
|
||||
await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization1, user);
|
||||
await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization2, user);
|
||||
|
||||
var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Contains(result, org => org.Id == organization1.Id);
|
||||
Assert.Contains(result, org => org.Id == organization2.Id);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetByVerifiedUserEmailDomainAsync_WithNonExistentUser_ReturnsEmpty(
|
||||
IOrganizationRepository organizationRepository)
|
||||
{
|
||||
var nonExistentUserId = Guid.NewGuid();
|
||||
|
||||
var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(nonExistentUserId);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests an edge case where some invited users are created linked to a UserId.
|
||||
/// This is defective behavior, but will take longer to fix - for now, we are defensive and expressly
|
||||
/// exclude such users from the results without relying on the inner join only.
|
||||
/// Invited-revoked users linked to a UserId remain intentionally unhandled for now as they have not caused
|
||||
/// any issues to date and we want to minimize edge cases.
|
||||
/// We will fix the underlying issue going forward: https://bitwarden.atlassian.net/browse/PM-22405
|
||||
/// </summary>
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetByVerifiedUserEmailDomainAsync_WithInvitedUserWithUserId_ReturnsEmpty(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationDomainRepository organizationDomainRepository)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var domainName = $"{id}.example.com";
|
||||
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"test+{id}@{domainName}",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateTestOrganizationAsync();
|
||||
|
||||
var organizationDomain = new OrganizationDomain
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
DomainName = domainName,
|
||||
Txt = "btw+12345",
|
||||
};
|
||||
organizationDomain.SetVerifiedDate();
|
||||
organizationDomain.SetNextRunDate(12);
|
||||
organizationDomain.SetJobRunCount();
|
||||
await organizationDomainRepository.CreateAsync(organizationDomain);
|
||||
|
||||
// Create invited user with matching email domain but UserId set (edge case)
|
||||
await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user.Id,
|
||||
Email = user.Email,
|
||||
Status = OrganizationUserStatusType.Invited,
|
||||
});
|
||||
|
||||
var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);
|
||||
|
||||
// Invited users should be excluded even if they have UserId set
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetByVerifiedUserEmailDomainAsync_WithAcceptedUser_ReturnsOrganization(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationDomainRepository organizationDomainRepository)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var domainName = $"{id}.example.com";
|
||||
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"test+{id}@{domainName}",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateTestOrganizationAsync();
|
||||
|
||||
var organizationDomain = new OrganizationDomain
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
DomainName = domainName,
|
||||
Txt = "btw+12345",
|
||||
};
|
||||
organizationDomain.SetVerifiedDate();
|
||||
organizationDomain.SetNextRunDate(12);
|
||||
organizationDomain.SetJobRunCount();
|
||||
await organizationDomainRepository.CreateAsync(organizationDomain);
|
||||
|
||||
await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(organization, user);
|
||||
|
||||
var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Equal(organization.Id, result.First().Id);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetByVerifiedUserEmailDomainAsync_WithRevokedUser_ReturnsOrganization(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationDomainRepository organizationDomainRepository)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var domainName = $"{id}.example.com";
|
||||
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"test+{id}@{domainName}",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateTestOrganizationAsync();
|
||||
|
||||
var organizationDomain = new OrganizationDomain
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
DomainName = domainName,
|
||||
Txt = "btw+12345",
|
||||
};
|
||||
organizationDomain.SetVerifiedDate();
|
||||
organizationDomain.SetNextRunDate(12);
|
||||
organizationDomain.SetJobRunCount();
|
||||
await organizationDomainRepository.CreateAsync(organizationDomain);
|
||||
|
||||
await organizationUserRepository.CreateRevokedTestOrganizationUserAsync(organization, user);
|
||||
|
||||
var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Equal(organization.Id, result.First().Id);
|
||||
}
|
||||
}
|
||||
@@ -8,254 +8,7 @@ namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories;
|
||||
|
||||
public class OrganizationRepositoryTests
|
||||
{
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task GetByClaimedUserDomainAsync_WithVerifiedDomain_Success(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationDomainRepository organizationDomainRepository)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var domainName = $"{id}.example.com";
|
||||
|
||||
var user1 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User 1",
|
||||
Email = $"test+{id}@{domainName}",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var user2 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User 2",
|
||||
Email = $"test+{id}@x-{domainName}", // Different domain
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var user3 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User 2",
|
||||
Email = $"test+{id}@{domainName}.example.com", // Different domain
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateAsync(new Organization
|
||||
{
|
||||
Name = $"Test Org {id}",
|
||||
BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULL
|
||||
Plan = "Test", // TODO: EF does not enforce this being NOT NULL
|
||||
PrivateKey = "privatekey",
|
||||
});
|
||||
|
||||
var organizationDomain = new OrganizationDomain
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
DomainName = domainName,
|
||||
Txt = "btw+12345",
|
||||
};
|
||||
organizationDomain.SetVerifiedDate();
|
||||
organizationDomain.SetNextRunDate(12);
|
||||
organizationDomain.SetJobRunCount();
|
||||
await organizationDomainRepository.CreateAsync(organizationDomain);
|
||||
|
||||
await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user1.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
ResetPasswordKey = "resetpasswordkey1",
|
||||
});
|
||||
|
||||
await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user2.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
ResetPasswordKey = "resetpasswordkey1",
|
||||
});
|
||||
|
||||
await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user3.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
ResetPasswordKey = "resetpasswordkey1",
|
||||
});
|
||||
|
||||
var user1Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user1.Id);
|
||||
var user2Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user2.Id);
|
||||
var user3Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user3.Id);
|
||||
|
||||
Assert.NotEmpty(user1Response);
|
||||
Assert.Equal(organization.Id, user1Response.First().Id);
|
||||
Assert.Empty(user2Response);
|
||||
Assert.Empty(user3Response);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task GetByVerifiedUserEmailDomainAsync_WithUnverifiedDomains_ReturnsEmpty(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationDomainRepository organizationDomainRepository)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var domainName = $"{id}.example.com";
|
||||
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"test+{id}@{domainName}",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateAsync(new Organization
|
||||
{
|
||||
Name = $"Test Org {id}",
|
||||
BillingEmail = user.Email,
|
||||
Plan = "Test",
|
||||
PrivateKey = "privatekey",
|
||||
});
|
||||
|
||||
var organizationDomain = new OrganizationDomain
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
DomainName = domainName,
|
||||
Txt = "btw+12345",
|
||||
};
|
||||
organizationDomain.SetNextRunDate(12);
|
||||
organizationDomain.SetJobRunCount();
|
||||
await organizationDomainRepository.CreateAsync(organizationDomain);
|
||||
|
||||
await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
ResetPasswordKey = "resetpasswordkey",
|
||||
});
|
||||
|
||||
var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task GetByVerifiedUserEmailDomainAsync_WithMultipleVerifiedDomains_ReturnsAllMatchingOrganizations(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationDomainRepository organizationDomainRepository)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var domainName = $"{id}.example.com";
|
||||
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"test+{id}@{domainName}",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var organization1 = await organizationRepository.CreateAsync(new Organization
|
||||
{
|
||||
Name = $"Test Org 1 {id}",
|
||||
BillingEmail = user.Email,
|
||||
Plan = "Test",
|
||||
PrivateKey = "privatekey1",
|
||||
});
|
||||
|
||||
var organization2 = await organizationRepository.CreateAsync(new Organization
|
||||
{
|
||||
Name = $"Test Org 2 {id}",
|
||||
BillingEmail = user.Email,
|
||||
Plan = "Test",
|
||||
PrivateKey = "privatekey2",
|
||||
});
|
||||
|
||||
var organizationDomain1 = new OrganizationDomain
|
||||
{
|
||||
OrganizationId = organization1.Id,
|
||||
DomainName = domainName,
|
||||
Txt = "btw+12345",
|
||||
};
|
||||
organizationDomain1.SetNextRunDate(12);
|
||||
organizationDomain1.SetJobRunCount();
|
||||
organizationDomain1.SetVerifiedDate();
|
||||
await organizationDomainRepository.CreateAsync(organizationDomain1);
|
||||
|
||||
var organizationDomain2 = new OrganizationDomain
|
||||
{
|
||||
OrganizationId = organization2.Id,
|
||||
DomainName = domainName,
|
||||
Txt = "btw+67890",
|
||||
};
|
||||
organizationDomain2.SetNextRunDate(12);
|
||||
organizationDomain2.SetJobRunCount();
|
||||
organizationDomain2.SetVerifiedDate();
|
||||
await organizationDomainRepository.CreateAsync(organizationDomain2);
|
||||
|
||||
await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization1.Id,
|
||||
UserId = user.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
ResetPasswordKey = "resetpasswordkey1",
|
||||
});
|
||||
|
||||
await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization2.Id,
|
||||
UserId = user.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
ResetPasswordKey = "resetpasswordkey2",
|
||||
});
|
||||
|
||||
var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Contains(result, org => org.Id == organization1.Id);
|
||||
Assert.Contains(result, org => org.Id == organization2.Id);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task GetByVerifiedUserEmailDomainAsync_WithNonExistentUser_ReturnsEmpty(
|
||||
IOrganizationRepository organizationRepository)
|
||||
{
|
||||
var nonExistentUserId = Guid.NewGuid();
|
||||
|
||||
var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(nonExistentUserId);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetManyByIdsAsync_ExistingOrganizations_ReturnsOrganizations(IOrganizationRepository organizationRepository)
|
||||
{
|
||||
var email = "test@email.com";
|
||||
@@ -287,7 +40,7 @@ public class OrganizationRepositoryTests
|
||||
await organizationRepository.DeleteAsync(organization2);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithUsersAndSponsorships_ReturnsCorrectCounts(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
@@ -356,7 +109,7 @@ public class OrganizationRepositoryTests
|
||||
Assert.Equal(4, result.Total); // Total occupied seats
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithNoUsersOrSponsorships_ReturnsZero(
|
||||
IOrganizationRepository organizationRepository)
|
||||
{
|
||||
@@ -372,7 +125,7 @@ public class OrganizationRepositoryTests
|
||||
Assert.Equal(0, result.Total);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithOnlyRevokedUsers_ReturnsZero(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
@@ -399,7 +152,7 @@ public class OrganizationRepositoryTests
|
||||
Assert.Equal(0, result.Total);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithOnlyExpiredSponsorships_ReturnsZero(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationSponsorshipRepository organizationSponsorshipRepository)
|
||||
@@ -424,7 +177,7 @@ public class OrganizationRepositoryTests
|
||||
Assert.Equal(0, result.Total);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
[Theory, DatabaseData]
|
||||
public async Task IncrementSeatCountAsync_IncrementsSeatCount(IOrganizationRepository organizationRepository)
|
||||
{
|
||||
var organization = await organizationRepository.CreateTestOrganizationAsync();
|
||||
@@ -438,7 +191,7 @@ public class OrganizationRepositoryTests
|
||||
Assert.Equal(8, result.Seats);
|
||||
}
|
||||
|
||||
[DatabaseData, DatabaseTheory]
|
||||
[DatabaseData, Theory]
|
||||
public async Task IncrementSeatCountAsync_GivenOrganizationHasNotChangedSeatCountBefore_WhenUpdatingOrgSeats_ThenSubscriptionUpdateIsSaved(
|
||||
IOrganizationRepository sutRepository)
|
||||
{
|
||||
@@ -462,7 +215,7 @@ public class OrganizationRepositoryTests
|
||||
await sutRepository.DeleteAsync(organization);
|
||||
}
|
||||
|
||||
[DatabaseData, DatabaseTheory]
|
||||
[DatabaseData, Theory]
|
||||
public async Task IncrementSeatCountAsync_GivenOrganizationHasChangedSeatCountBeforeAndRecordExists_WhenUpdatingOrgSeats_ThenSubscriptionUpdateIsSaved(
|
||||
IOrganizationRepository sutRepository)
|
||||
{
|
||||
@@ -487,7 +240,7 @@ public class OrganizationRepositoryTests
|
||||
await sutRepository.DeleteAsync(organization);
|
||||
}
|
||||
|
||||
[DatabaseData, DatabaseTheory]
|
||||
[DatabaseData, Theory]
|
||||
public async Task GetOrganizationsForSubscriptionSyncAsync_GivenOrganizationHasChangedSeatCount_WhenGettingOrgsToUpdate_ThenReturnsOrgSubscriptionUpdate(
|
||||
IOrganizationRepository sutRepository)
|
||||
{
|
||||
@@ -510,7 +263,7 @@ public class OrganizationRepositoryTests
|
||||
await sutRepository.DeleteAsync(organization);
|
||||
}
|
||||
|
||||
[DatabaseData, DatabaseTheory]
|
||||
[DatabaseData, Theory]
|
||||
public async Task UpdateSuccessfulOrganizationSyncStatusAsync_GivenOrganizationHasChangedSeatCount_WhenUpdatingStatus_ThenSuccessfullyUpdatesOrgSoItDoesntSync(
|
||||
IOrganizationRepository sutRepository)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.OrganizationUserRepository;
|
||||
|
||||
public class GetManyByOrganizationWithClaimedDomainsAsyncTests
|
||||
{
|
||||
[Theory, DatabaseData]
|
||||
public async Task WithVerifiedDomain_WithOneMatchingEmailDomain_ReturnsSingle(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationDomainRepository organizationDomainRepository)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var domainName = $"{id}.example.com";
|
||||
|
||||
var user1 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User 1",
|
||||
Email = $"test+{id}@{domainName}",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var user2 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User 2",
|
||||
Email = $"test+{id}@x-{domainName}", // Different domain
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var user3 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User 3",
|
||||
Email = $"test+{id}@{domainName}.example.com", // Different domain
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateTestOrganizationAsync();
|
||||
|
||||
var organizationDomain = new OrganizationDomain
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
DomainName = domainName,
|
||||
Txt = "btw+12345",
|
||||
};
|
||||
organizationDomain.SetVerifiedDate();
|
||||
organizationDomain.SetNextRunDate(12);
|
||||
organizationDomain.SetJobRunCount();
|
||||
await organizationDomainRepository.CreateAsync(organizationDomain);
|
||||
|
||||
var orgUser1 = await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user1);
|
||||
await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user2);
|
||||
await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user3);
|
||||
|
||||
var result = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(orgUser1.Id, result.Single().Id);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task WithNoVerifiedDomain_ReturnsEmpty(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationDomainRepository organizationDomainRepository)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var domainName = $"{id}.example.com";
|
||||
|
||||
var user = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User",
|
||||
Email = $"test+{id}@{domainName}",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateTestOrganizationAsync();
|
||||
|
||||
// Create domain but do NOT verify it
|
||||
var organizationDomain = new OrganizationDomain
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
DomainName = domainName,
|
||||
Txt = "btw+12345",
|
||||
};
|
||||
organizationDomain.SetNextRunDate(12);
|
||||
// Note: NOT calling SetVerifiedDate()
|
||||
await organizationDomainRepository.CreateAsync(organizationDomain);
|
||||
|
||||
await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user);
|
||||
|
||||
var result = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests an edge case where some invited users are created linked to a UserId.
|
||||
/// This is defective behavior, but will take longer to fix - for now, we are defensive and expressly
|
||||
/// exclude such users from the results without relying on the inner join only.
|
||||
/// Invited-revoked users linked to a UserId remain intentionally unhandled for now as they have not caused
|
||||
/// any issues to date and we want to minimize edge cases.
|
||||
/// We will fix the underlying issue going forward: https://bitwarden.atlassian.net/browse/PM-22405
|
||||
/// </summary>
|
||||
[Theory, DatabaseData]
|
||||
public async Task WithVerifiedDomain_ExcludesInvitedUsers(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationDomainRepository organizationDomainRepository)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var domainName = $"{id}.example.com";
|
||||
|
||||
var invitedUser = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Invited User",
|
||||
Email = $"invited+{id}@{domainName}",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var confirmedUser = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Confirmed User",
|
||||
Email = $"confirmed+{id}@{domainName}",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateTestOrganizationAsync();
|
||||
|
||||
var organizationDomain = new OrganizationDomain
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
DomainName = domainName,
|
||||
Txt = "btw+12345",
|
||||
};
|
||||
organizationDomain.SetVerifiedDate();
|
||||
organizationDomain.SetNextRunDate(12);
|
||||
organizationDomain.SetJobRunCount();
|
||||
await organizationDomainRepository.CreateAsync(organizationDomain);
|
||||
|
||||
// Create invited user with UserId set (edge case - should be excluded even with UserId linked)
|
||||
var invitedOrgUser = await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = invitedUser.Id, // Edge case: invited user with UserId set
|
||||
Email = invitedUser.Email,
|
||||
Status = OrganizationUserStatusType.Invited,
|
||||
Type = OrganizationUserType.User
|
||||
});
|
||||
|
||||
// Create confirmed user linked by UserId only (no Email field set)
|
||||
var confirmedOrgUser = await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, confirmedUser);
|
||||
|
||||
var result = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id);
|
||||
|
||||
Assert.NotNull(result);
|
||||
var claimedUser = Assert.Single(result);
|
||||
Assert.Equal(confirmedOrgUser.Id, claimedUser.Id);
|
||||
}
|
||||
}
|
||||
@@ -599,136 +599,6 @@ public class OrganizationUserRepositoryTests
|
||||
Assert.Null(orgWithoutSsoDetails.SsoConfig);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task GetManyByOrganizationWithClaimedDomainsAsync_WithVerifiedDomain_WithOneMatchingEmailDomain_ReturnsSingle(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationDomainRepository organizationDomainRepository)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var domainName = $"{id}.example.com";
|
||||
|
||||
var user1 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User 1",
|
||||
Email = $"test+{id}@{domainName}",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var user2 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User 2",
|
||||
Email = $"test+{id}@x-{domainName}", // Different domain
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var user3 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User 2",
|
||||
Email = $"test+{id}@{domainName}.example.com", // Different domain
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 1,
|
||||
KdfMemory = 2,
|
||||
KdfParallelism = 3
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateAsync(new Organization
|
||||
{
|
||||
Name = $"Test Org {id}",
|
||||
BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULL
|
||||
Plan = "Test", // TODO: EF does not enforce this being NOT NULL
|
||||
PrivateKey = "privatekey",
|
||||
UsePolicies = false,
|
||||
UseSso = false,
|
||||
UseKeyConnector = false,
|
||||
UseScim = false,
|
||||
UseGroups = false,
|
||||
UseDirectory = false,
|
||||
UseEvents = false,
|
||||
UseTotp = false,
|
||||
Use2fa = false,
|
||||
UseApi = false,
|
||||
UseResetPassword = false,
|
||||
UseSecretsManager = false,
|
||||
SelfHost = false,
|
||||
UsersGetPremium = false,
|
||||
UseCustomPermissions = false,
|
||||
Enabled = true,
|
||||
UsePasswordManager = false,
|
||||
LimitCollectionCreation = false,
|
||||
LimitCollectionDeletion = false,
|
||||
LimitItemDeletion = false,
|
||||
AllowAdminAccessToAllCollectionItems = false,
|
||||
UseRiskInsights = false,
|
||||
UseAdminSponsoredFamilies = false,
|
||||
UsePhishingBlocker = false,
|
||||
UseDisableSmAdsForUsers = false,
|
||||
});
|
||||
|
||||
var organizationDomain = new OrganizationDomain
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
DomainName = domainName,
|
||||
Txt = "btw+12345",
|
||||
};
|
||||
organizationDomain.SetVerifiedDate();
|
||||
organizationDomain.SetNextRunDate(12);
|
||||
organizationDomain.SetJobRunCount();
|
||||
await organizationDomainRepository.CreateAsync(organizationDomain);
|
||||
|
||||
var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user1.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
Type = OrganizationUserType.Owner,
|
||||
ResetPasswordKey = "resetpasswordkey1",
|
||||
AccessSecretsManager = false
|
||||
});
|
||||
|
||||
await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user2.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
Type = OrganizationUserType.User,
|
||||
ResetPasswordKey = "resetpasswordkey1",
|
||||
AccessSecretsManager = false
|
||||
});
|
||||
|
||||
await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user3.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
Type = OrganizationUserType.User,
|
||||
ResetPasswordKey = "resetpasswordkey1",
|
||||
AccessSecretsManager = false
|
||||
});
|
||||
|
||||
var responseModel = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id);
|
||||
|
||||
Assert.NotNull(responseModel);
|
||||
Assert.Single(responseModel);
|
||||
Assert.Equal(orgUser1.Id, responseModel.Single().Id);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task CreateManyAsync_NoId_Works(IOrganizationRepository organizationRepository,
|
||||
IUserRepository userRepository,
|
||||
@@ -1237,70 +1107,6 @@ public class OrganizationUserRepositoryTests
|
||||
Assert.DoesNotContain(user1Result.Collections, c => c.Id == defaultUserCollection.Id);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task GetManyByOrganizationWithClaimedDomainsAsync_WithNoVerifiedDomain_ReturnsEmpty(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationDomainRepository organizationDomainRepository)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var domainName = $"{id}.example.com";
|
||||
var requestTime = DateTime.UtcNow;
|
||||
|
||||
var user1 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
Name = "Test User 1",
|
||||
Email = $"test+{id}@{domainName}",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
CreationDate = requestTime,
|
||||
RevisionDate = requestTime,
|
||||
AccountRevisionDate = requestTime
|
||||
});
|
||||
|
||||
var organization = await organizationRepository.CreateAsync(new Organization
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
Name = $"Test Org {id}",
|
||||
BillingEmail = user1.Email,
|
||||
Plan = "Test",
|
||||
Enabled = true,
|
||||
CreationDate = requestTime,
|
||||
RevisionDate = requestTime
|
||||
});
|
||||
|
||||
// Create domain but do NOT verify it
|
||||
var organizationDomain = new OrganizationDomain
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
OrganizationId = organization.Id,
|
||||
DomainName = domainName,
|
||||
Txt = "btw+12345",
|
||||
CreationDate = requestTime
|
||||
};
|
||||
organizationDomain.SetNextRunDate(12);
|
||||
// Note: NOT calling SetVerifiedDate()
|
||||
await organizationDomainRepository.CreateAsync(organizationDomain);
|
||||
|
||||
await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user1.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
Type = OrganizationUserType.Owner,
|
||||
CreationDate = requestTime,
|
||||
RevisionDate = requestTime
|
||||
});
|
||||
|
||||
var responseModel = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id);
|
||||
|
||||
Assert.NotNull(responseModel);
|
||||
Assert.Empty(responseModel);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task DeleteAsync_WithNullEmail_DoesNotSetDefaultUserCollectionEmail(IUserRepository userRepository,
|
||||
ICollectionRepository collectionRepository,
|
||||
|
||||
@@ -128,7 +128,6 @@ public class DatabaseDataAttribute : DataAttribute
|
||||
|
||||
private void AddDapperServices(IServiceCollection services, Database database)
|
||||
{
|
||||
services.AddDapperRepositories(SelfHosted);
|
||||
var globalSettings = new GlobalSettings
|
||||
{
|
||||
DatabaseProvider = "sqlServer",
|
||||
@@ -141,6 +140,7 @@ public class DatabaseDataAttribute : DataAttribute
|
||||
UserRequestExpiration = TimeSpan.FromMinutes(15),
|
||||
}
|
||||
};
|
||||
services.AddDapperRepositories(SelfHosted);
|
||||
services.AddSingleton(globalSettings);
|
||||
services.AddSingleton<IGlobalSettings>(globalSettings);
|
||||
services.AddSingleton(database);
|
||||
@@ -160,7 +160,6 @@ public class DatabaseDataAttribute : DataAttribute
|
||||
private void AddEfServices(IServiceCollection services, Database database)
|
||||
{
|
||||
services.SetupEntityFramework(database.ConnectionString, database.Type);
|
||||
services.AddPasswordManagerEFRepositories(SelfHosted);
|
||||
|
||||
var globalSettings = new GlobalSettings
|
||||
{
|
||||
@@ -169,6 +168,7 @@ public class DatabaseDataAttribute : DataAttribute
|
||||
UserRequestExpiration = TimeSpan.FromMinutes(15),
|
||||
},
|
||||
};
|
||||
services.AddPasswordManagerEFRepositories(SelfHosted);
|
||||
services.AddSingleton(globalSettings);
|
||||
services.AddSingleton<IGlobalSettings>(globalSettings);
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<UserSecretsId>6570f288-5c2c-47ad-8978-f3da255079c2</UserSecretsId>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -47,7 +47,7 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
|
||||
/// </remarks>
|
||||
public bool ManagesDatabase { get; set; } = true;
|
||||
|
||||
private readonly List<Action<IServiceCollection>> _configureTestServices = new();
|
||||
protected readonly List<Action<IServiceCollection>> _configureTestServices = new();
|
||||
private readonly List<Action<IConfigurationBuilder>> _configureAppConfiguration = new();
|
||||
|
||||
public void SubstituteService<TService>(Action<TService> mockService)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
40
test/SeederApi.IntegrationTest/HttpClientExtensions.cs
Normal file
40
test/SeederApi.IntegrationTest/HttpClientExtensions.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Bit.SeederApi.IntegrationTest;
|
||||
|
||||
public static class HttpClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a POST request with JSON content and attaches the x-play-id header.
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The type of the value to serialize.</typeparam>
|
||||
/// <param name="client">The HTTP client.</param>
|
||||
/// <param name="requestUri">The URI the request is sent to.</param>
|
||||
/// <param name="value">The value to serialize.</param>
|
||||
/// <param name="playId">The play ID to attach as x-play-id header.</param>
|
||||
/// <param name="options">Options to control the behavior during serialization.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>The task object representing the asynchronous operation.</returns>
|
||||
public static Task<HttpResponseMessage> PostAsJsonAsync<TValue>(
|
||||
this HttpClient client,
|
||||
[StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri,
|
||||
TValue value,
|
||||
string playId,
|
||||
JsonSerializerOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(playId))
|
||||
{
|
||||
throw new ArgumentException("Play ID cannot be null or whitespace.", nameof(playId));
|
||||
}
|
||||
|
||||
var content = JsonContent.Create(value, mediaType: null, options);
|
||||
content.Headers.Remove("x-play-id");
|
||||
content.Headers.Add("x-play-id", playId);
|
||||
|
||||
return client.PostAsync(requestUri, content, cancellationToken);
|
||||
}
|
||||
}
|
||||
75
test/SeederApi.IntegrationTest/QueryControllerTest.cs
Normal file
75
test/SeederApi.IntegrationTest/QueryControllerTest.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using System.Net;
|
||||
using Bit.SeederApi.Models.Request;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.SeederApi.IntegrationTest;
|
||||
|
||||
public class QueryControllerTests : IClassFixture<SeederApiApplicationFactory>, IAsyncLifetime
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly SeederApiApplicationFactory _factory;
|
||||
|
||||
public QueryControllerTests(SeederApiApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryEndpoint_WithValidQueryAndArguments_ReturnsOk()
|
||||
{
|
||||
var testEmail = $"emergency-test-{Guid.NewGuid()}@bitwarden.com";
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/query", new QueryRequestModel
|
||||
{
|
||||
Template = "EmergencyAccessInviteQuery",
|
||||
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail })
|
||||
});
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.NotNull(result);
|
||||
|
||||
var urls = System.Text.Json.JsonSerializer.Deserialize<List<string>>(result);
|
||||
Assert.NotNull(urls);
|
||||
// For a non-existent email, we expect an empty list
|
||||
Assert.Empty(urls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryEndpoint_WithInvalidQueryName_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/query", new QueryRequestModel
|
||||
{
|
||||
Template = "NonExistentQuery",
|
||||
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = "test@example.com" })
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryEndpoint_WithMissingRequiredField_ReturnsBadRequest()
|
||||
{
|
||||
// EmergencyAccessInviteQuery requires 'email' field
|
||||
var response = await _client.PostAsJsonAsync("/query", new QueryRequestModel
|
||||
{
|
||||
Template = "EmergencyAccessInviteQuery",
|
||||
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { wrongField = "value" })
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
}
|
||||
222
test/SeederApi.IntegrationTest/SeedControllerTest.cs
Normal file
222
test/SeederApi.IntegrationTest/SeedControllerTest.cs
Normal file
@@ -0,0 +1,222 @@
|
||||
using System.Net;
|
||||
using Bit.SeederApi.Models.Request;
|
||||
using Bit.SeederApi.Models.Response;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.SeederApi.IntegrationTest;
|
||||
|
||||
public class SeedControllerTests : IClassFixture<SeederApiApplicationFactory>, IAsyncLifetime
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly SeederApiApplicationFactory _factory;
|
||||
|
||||
public SeedControllerTests(SeederApiApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
// Clean up any seeded data after each test
|
||||
await _client.DeleteAsync("/seed");
|
||||
_client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedEndpoint_WithValidScene_ReturnsOk()
|
||||
{
|
||||
var testEmail = $"seed-test-{Guid.NewGuid()}@bitwarden.com";
|
||||
var playId = Guid.NewGuid().ToString();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
|
||||
{
|
||||
Template = "SingleUserScene",
|
||||
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail })
|
||||
}, playId);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<SceneResponseModel>();
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.MangleMap);
|
||||
Assert.Null(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedEndpoint_WithInvalidSceneName_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
|
||||
{
|
||||
Template = "NonExistentScene",
|
||||
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = "test@example.com" })
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedEndpoint_WithMissingRequiredField_ReturnsBadRequest()
|
||||
{
|
||||
// SingleUserScene requires 'email' field
|
||||
var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
|
||||
{
|
||||
Template = "SingleUserScene",
|
||||
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { wrongField = "value" })
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteEndpoint_WithValidPlayId_ReturnsOk()
|
||||
{
|
||||
var testEmail = $"delete-test-{Guid.NewGuid()}@bitwarden.com";
|
||||
var playId = Guid.NewGuid().ToString();
|
||||
|
||||
var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
|
||||
{
|
||||
Template = "SingleUserScene",
|
||||
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail })
|
||||
}, playId);
|
||||
|
||||
seedResponse.EnsureSuccessStatusCode();
|
||||
var seedResult = await seedResponse.Content.ReadFromJsonAsync<SceneResponseModel>();
|
||||
Assert.NotNull(seedResult);
|
||||
|
||||
var deleteResponse = await _client.DeleteAsync($"/seed/{playId}");
|
||||
deleteResponse.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteEndpoint_WithInvalidPlayId_ReturnsOk()
|
||||
{
|
||||
// DestroyRecipe is idempotent - returns null for non-existent play IDs
|
||||
var nonExistentPlayId = Guid.NewGuid().ToString();
|
||||
var response = await _client.DeleteAsync($"/seed/{nonExistentPlayId}");
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal($$"""{"playId":"{{nonExistentPlayId}}"}""", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteBatchEndpoint_WithValidPlayIds_ReturnsOk()
|
||||
{
|
||||
// Create multiple seeds with different play IDs
|
||||
var playIds = new List<string>();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var playId = Guid.NewGuid().ToString();
|
||||
playIds.Add(playId);
|
||||
|
||||
var testEmail = $"batch-test-{Guid.NewGuid()}@bitwarden.com";
|
||||
var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
|
||||
{
|
||||
Template = "SingleUserScene",
|
||||
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail })
|
||||
}, playId);
|
||||
|
||||
seedResponse.EnsureSuccessStatusCode();
|
||||
var seedResult = await seedResponse.Content.ReadFromJsonAsync<SceneResponseModel>();
|
||||
Assert.NotNull(seedResult);
|
||||
}
|
||||
|
||||
// Delete them in batch
|
||||
var request = new HttpRequestMessage(HttpMethod.Delete, "/seed/batch")
|
||||
{
|
||||
Content = JsonContent.Create(playIds)
|
||||
};
|
||||
var deleteResponse = await _client.SendAsync(request);
|
||||
deleteResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await deleteResponse.Content.ReadFromJsonAsync<BatchDeleteResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Batch delete completed successfully", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteBatchEndpoint_WithSomeInvalidIds_ReturnsOk()
|
||||
{
|
||||
// DestroyRecipe is idempotent - batch delete succeeds even with non-existent IDs
|
||||
// Create one valid seed with a play ID
|
||||
var validPlayId = Guid.NewGuid().ToString();
|
||||
var testEmail = $"batch-partial-test-{Guid.NewGuid()}@bitwarden.com";
|
||||
|
||||
var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
|
||||
{
|
||||
Template = "SingleUserScene",
|
||||
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail })
|
||||
}, validPlayId);
|
||||
|
||||
seedResponse.EnsureSuccessStatusCode();
|
||||
var seedResult = await seedResponse.Content.ReadFromJsonAsync<SceneResponseModel>();
|
||||
Assert.NotNull(seedResult);
|
||||
|
||||
// Try to delete with mix of valid and invalid IDs
|
||||
var playIds = new List<string> { validPlayId, Guid.NewGuid().ToString(), Guid.NewGuid().ToString() };
|
||||
var request = new HttpRequestMessage(HttpMethod.Delete, "/seed/batch")
|
||||
{
|
||||
Content = JsonContent.Create(playIds)
|
||||
};
|
||||
var deleteResponse = await _client.SendAsync(request);
|
||||
|
||||
deleteResponse.EnsureSuccessStatusCode();
|
||||
var result = await deleteResponse.Content.ReadFromJsonAsync<BatchDeleteResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Batch delete completed successfully", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAllEndpoint_DeletesAllSeededData()
|
||||
{
|
||||
// Create multiple seeds
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var playId = Guid.NewGuid().ToString();
|
||||
var testEmail = $"deleteall-test-{Guid.NewGuid()}@bitwarden.com";
|
||||
|
||||
var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
|
||||
{
|
||||
Template = "SingleUserScene",
|
||||
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail })
|
||||
}, playId);
|
||||
|
||||
seedResponse.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
// Delete all
|
||||
var deleteResponse = await _client.DeleteAsync("/seed");
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedEndpoint_VerifyResponseContainsMangleMapAndResult()
|
||||
{
|
||||
var testEmail = $"verify-response-{Guid.NewGuid()}@bitwarden.com";
|
||||
var playId = Guid.NewGuid().ToString();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
|
||||
{
|
||||
Template = "SingleUserScene",
|
||||
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail })
|
||||
}, playId);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var jsonString = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Verify the response contains MangleMap and Result fields
|
||||
Assert.Contains("mangleMap", jsonString, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("result", jsonString, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private class BatchDeleteResponse
|
||||
{
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
|
||||
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\util\SeederApi\SeederApi.csproj" />
|
||||
<ProjectReference Include="..\..\util\Seeder\Seeder.csproj" />
|
||||
<ProjectReference Include="..\IntegrationTestCommon\IntegrationTestCommon.csproj" />
|
||||
|
||||
<Content Include="..\..\util\SeederApi\appsettings.*.json">
|
||||
<Link>%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,18 @@
|
||||
using Bit.Core.Services;
|
||||
using Bit.IntegrationTestCommon;
|
||||
using Bit.IntegrationTestCommon.Factories;
|
||||
|
||||
namespace Bit.SeederApi.IntegrationTest;
|
||||
|
||||
public class SeederApiApplicationFactory : WebApplicationFactoryBase<Startup>
|
||||
{
|
||||
public SeederApiApplicationFactory()
|
||||
{
|
||||
TestDatabase = new SqliteTestDatabase();
|
||||
_configureTestServices.Add(serviceCollection =>
|
||||
{
|
||||
serviceCollection.AddSingleton<IPlayIdService, NeverPlayIdServices>();
|
||||
serviceCollection.AddHttpContextAccessor();
|
||||
});
|
||||
}
|
||||
}
|
||||
1
test/Server.IntegrationTest/Properties/AssemblyInfo.cs
Normal file
1
test/Server.IntegrationTest/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1 @@
|
||||
[assembly: CaptureTrace]
|
||||
23
test/Server.IntegrationTest/Server.IntegrationTest.csproj
Normal file
23
test/Server.IntegrationTest/Server.IntegrationTest.csproj
Normal file
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0" />
|
||||
<PackageReference Include="xunit.v3" Version="3.0.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\util\Server\Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
45
test/Server.IntegrationTest/Server.cs
Normal file
45
test/Server.IntegrationTest/Server.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
|
||||
namespace Bit.Server.IntegrationTest;
|
||||
|
||||
public class Server : WebApplicationFactory<Program>
|
||||
{
|
||||
public string? ContentRoot { get; set; }
|
||||
public string? WebRoot { get; set; }
|
||||
public bool ServeUnknown { get; set; }
|
||||
public bool? WebVault { get; set; }
|
||||
public string? AppIdLocation { get; set; }
|
||||
|
||||
protected override IWebHostBuilder? CreateWebHostBuilder()
|
||||
{
|
||||
var args = new List<string>
|
||||
{
|
||||
"/contentRoot",
|
||||
ContentRoot ?? "",
|
||||
"/webRoot",
|
||||
WebRoot ?? "",
|
||||
"/serveUnknown",
|
||||
ServeUnknown.ToString().ToLowerInvariant(),
|
||||
};
|
||||
|
||||
if (WebVault.HasValue)
|
||||
{
|
||||
args.Add("/webVault");
|
||||
args.Add(WebVault.Value.ToString().ToLowerInvariant());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(AppIdLocation))
|
||||
{
|
||||
args.Add("/appIdLocation");
|
||||
args.Add(AppIdLocation);
|
||||
}
|
||||
|
||||
var builder = WebHostBuilderFactory.CreateFromTypesAssemblyEntryPoint<Program>([.. args])
|
||||
?? throw new InvalidProgramException("Could not create builder from assembly.");
|
||||
|
||||
builder.UseSetting("TEST_CONTENTROOT_SERVER", ContentRoot);
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
102
test/Server.IntegrationTest/ServerTests.cs
Normal file
102
test/Server.IntegrationTest/ServerTests.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System.Net;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Bit.Server.IntegrationTest;
|
||||
|
||||
public class ServerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AttachmentsStyleUse()
|
||||
{
|
||||
using var tempDir = new TempDir();
|
||||
|
||||
await tempDir.WriteAsync("my-file.txt", "Hello!");
|
||||
|
||||
using var server = new Server
|
||||
{
|
||||
ContentRoot = tempDir.Info.FullName,
|
||||
WebRoot = ".",
|
||||
ServeUnknown = true,
|
||||
};
|
||||
|
||||
var client = server.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/my-file.txt", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("Hello!", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebVaultStyleUse()
|
||||
{
|
||||
using var tempDir = new TempDir();
|
||||
|
||||
await tempDir.WriteAsync("index.html", "<html></html>");
|
||||
await tempDir.WriteAsync(Path.Join("app", "file.js"), "AppStuff");
|
||||
await tempDir.WriteAsync(Path.Join("locales", "file.json"), "LocalesStuff");
|
||||
await tempDir.WriteAsync(Path.Join("fonts", "file.ttf"), "FontsStuff");
|
||||
await tempDir.WriteAsync(Path.Join("connectors", "file.js"), "ConnectorsStuff");
|
||||
await tempDir.WriteAsync(Path.Join("scripts", "file.js"), "ScriptsStuff");
|
||||
await tempDir.WriteAsync(Path.Join("images", "file.avif"), "ImagesStuff");
|
||||
await tempDir.WriteAsync(Path.Join("test", "file.json"), "{}");
|
||||
|
||||
using var server = new Server
|
||||
{
|
||||
ContentRoot = tempDir.Info.FullName,
|
||||
WebRoot = ".",
|
||||
ServeUnknown = false,
|
||||
WebVault = true,
|
||||
AppIdLocation = Path.Join(tempDir.Info.FullName, "test", "file.json"),
|
||||
};
|
||||
|
||||
var client = server.CreateClient();
|
||||
|
||||
// Going to root should return the default file
|
||||
var response = await client.GetAsync("", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("<html></html>", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
// No caching on the default document
|
||||
Assert.Null(response.Headers.CacheControl?.MaxAge);
|
||||
|
||||
await ExpectMaxAgeAsync("app/file.js", TimeSpan.FromDays(14));
|
||||
await ExpectMaxAgeAsync("locales/file.json", TimeSpan.FromDays(14));
|
||||
await ExpectMaxAgeAsync("fonts/file.ttf", TimeSpan.FromDays(14));
|
||||
await ExpectMaxAgeAsync("connectors/file.js", TimeSpan.FromDays(14));
|
||||
await ExpectMaxAgeAsync("scripts/file.js", TimeSpan.FromDays(14));
|
||||
await ExpectMaxAgeAsync("images/file.avif", TimeSpan.FromDays(7));
|
||||
|
||||
response = await client.GetAsync("app-id.json", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
async Task ExpectMaxAgeAsync(string path, TimeSpan maxAge)
|
||||
{
|
||||
response = await client.GetAsync(path);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.NotNull(response.Headers.CacheControl);
|
||||
Assert.Equal(maxAge, response.Headers.CacheControl.MaxAge);
|
||||
}
|
||||
}
|
||||
|
||||
private class TempDir([CallerMemberName] string test = null!) : IDisposable
|
||||
{
|
||||
public DirectoryInfo Info { get; } = Directory.CreateTempSubdirectory(test);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Info.Delete(recursive: true);
|
||||
}
|
||||
|
||||
public async Task WriteAsync(string fileName, string content)
|
||||
{
|
||||
var fullPath = Path.Join(Info.FullName, fileName);
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
if (directory != null)
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(fullPath, content, TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
102
test/SharedWeb.Test/PlayIdMiddlewareTests.cs
Normal file
102
test/SharedWeb.Test/PlayIdMiddlewareTests.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using Bit.Core.Services;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using NSubstitute;
|
||||
|
||||
namespace SharedWeb.Test;
|
||||
|
||||
public class PlayIdMiddlewareTests
|
||||
{
|
||||
private readonly PlayIdService _playIdService;
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly PlayIdMiddleware _middleware;
|
||||
|
||||
public PlayIdMiddlewareTests()
|
||||
{
|
||||
var hostEnvironment = Substitute.For<IHostEnvironment>();
|
||||
hostEnvironment.EnvironmentName.Returns(Environments.Development);
|
||||
|
||||
_playIdService = new PlayIdService(hostEnvironment);
|
||||
_next = Substitute.For<RequestDelegate>();
|
||||
_middleware = new PlayIdMiddleware(_next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithValidPlayId_SetsPlayIdAndCallsNext()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Headers["x-play-id"] = "test-play-id";
|
||||
|
||||
await _middleware.Invoke(context, _playIdService);
|
||||
|
||||
Assert.Equal("test-play-id", _playIdService.PlayId);
|
||||
await _next.Received(1).Invoke(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithoutPlayIdHeader_CallsNext()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
|
||||
await _middleware.Invoke(context, _playIdService);
|
||||
|
||||
Assert.Null(_playIdService.PlayId);
|
||||
await _next.Received(1).Invoke(context);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\t")]
|
||||
public async Task Invoke_WithEmptyOrWhitespacePlayId_Returns400(string playId)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
context.Request.Headers["x-play-id"] = playId;
|
||||
|
||||
await _middleware.Invoke(context, _playIdService);
|
||||
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
|
||||
await _next.DidNotReceive().Invoke(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithPlayIdExceedingMaxLength_Returns400()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
var longPlayId = new string('a', 257); // Exceeds 256 character limit
|
||||
context.Request.Headers["x-play-id"] = longPlayId;
|
||||
|
||||
await _middleware.Invoke(context, _playIdService);
|
||||
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
|
||||
await _next.DidNotReceive().Invoke(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithPlayIdAtMaxLength_SetsPlayIdAndCallsNext()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
var maxLengthPlayId = new string('a', 256); // Exactly 256 characters
|
||||
context.Request.Headers["x-play-id"] = maxLengthPlayId;
|
||||
|
||||
await _middleware.Invoke(context, _playIdService);
|
||||
|
||||
Assert.Equal(maxLengthPlayId, _playIdService.PlayId);
|
||||
await _next.Received(1).Invoke(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithSpecialCharactersInPlayId_SetsPlayIdAndCallsNext()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Headers["x-play-id"] = "test-play_id.123";
|
||||
|
||||
await _middleware.Invoke(context, _playIdService);
|
||||
|
||||
Assert.Equal("test-play_id.123", _playIdService.PlayId);
|
||||
await _next.Received(1).Invoke(context);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user