1
0
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:
John Harrington
2026-01-23 17:01:41 -07:00
committed by GitHub
298 changed files with 23849 additions and 3807 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"/>.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

@@ -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' " />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
[assembly: CaptureTrace]

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

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

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

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