1
0
mirror of https://github.com/bitwarden/server synced 2026-01-02 00:23:40 +00:00

Merge branch 'main' into auth/pm-22975/client-version-validator

This commit is contained in:
Patrick Pimentel
2025-12-08 13:30:14 -05:00
156 changed files with 7890 additions and 1556 deletions

View File

@@ -0,0 +1,63 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.Models.Request;
using Bit.Seeder.Recipes;
using Xunit;
using Xunit.Abstractions;
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper)
{
/// <summary>
/// Tests PUT /organizations/{orgId}/groups/{id}
/// </summary>
[Theory(Skip = "Performance test")]
[InlineData(10, 5)]
//[InlineData(100, 10)]
//[InlineData(1000, 20)]
public async Task UpdateGroup_WithUsersAndCollections(int userCount, int collectionCount)
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
var collectionIds = collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0);
var groupIds = groupsSeeder.AddToOrganization(orgId, 1, orgUserIds, 0);
var groupId = groupIds.First();
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
var updateRequest = new GroupRequestModel
{
Name = "Updated Group Name",
Collections = collectionIds.Select(c => new SelectionReadOnlyRequestModel { Id = c, ReadOnly = false, HidePasswords = false, Manage = false }),
Users = orgUserIds
};
var requestContent = new StringContent(JsonSerializer.Serialize(updateRequest), Encoding.UTF8, "application/json");
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.PutAsync($"/organizations/{orgId}/groups/{groupId}", requestContent);
stopwatch.Stop();
testOutputHelper.WriteLine($"PUT /organizations/{{orgId}}/groups/{{id}} - Users: {orgUserIds.Count}; Collections: {collectionIds.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}

View File

@@ -0,0 +1,347 @@
using System.Net;
using Bit.Api.AdminConsole.Models.Request.Organizations;
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;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
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;
public class OrganizationUserControllerBulkRevokeTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
{
private readonly HttpClient _client;
private readonly ApiApplicationFactory _factory;
private readonly LoginHelper _loginHelper;
private Organization _organization = null!;
private string _ownerEmail = null!;
public OrganizationUserControllerBulkRevokeTests(ApiApplicationFactory apiFactory)
{
_factory = apiFactory;
_factory.SubstituteService<IFeatureService>(featureService =>
{
featureService
.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2)
.Returns(true);
});
_client = _factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
}
public async Task InitializeAsync()
{
_ownerEmail = $"org-user-bulk-revoke-test-{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(_ownerEmail);
(_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseMonthly,
ownerEmail: _ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card);
}
public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task BulkRevoke_Success()
{
var (ownerEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
_organization.Id, OrganizationUserType.Owner);
await _loginHelper.LoginAsync(ownerEmail);
var (_, orgUser1) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User);
var (_, orgUser2) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User);
var organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
var request = new OrganizationUserBulkRequestModel
{
Ids = [orgUser1.Id, orgUser2.Id]
};
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
var content = await httpResponse.Content.ReadFromJsonAsync<ListResponseModel<OrganizationUserBulkResponseModel>>();
Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode);
Assert.NotNull(content);
Assert.Equal(2, content.Data.Count());
Assert.All(content.Data, r => Assert.Empty(r.Error));
var actualUsers = await organizationUserRepository.GetManyAsync([orgUser1.Id, orgUser2.Id]);
Assert.All(actualUsers, u => Assert.Equal(OrganizationUserStatusType.Revoked, u.Status));
}
[Fact]
public async Task BulkRevoke_AsAdmin_Success()
{
var (adminEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
_organization.Id, OrganizationUserType.Admin);
await _loginHelper.LoginAsync(adminEmail);
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User);
var request = new OrganizationUserBulkRequestModel
{
Ids = [orgUser.Id]
};
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
var content = await httpResponse.Content.ReadFromJsonAsync<ListResponseModel<OrganizationUserBulkResponseModel>>();
Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode);
Assert.NotNull(content);
Assert.Single(content.Data);
Assert.All(content.Data, r => Assert.Empty(r.Error));
var actualUser = await _factory.GetService<IOrganizationUserRepository>().GetByIdAsync(orgUser.Id);
Assert.NotNull(actualUser);
Assert.Equal(OrganizationUserStatusType.Revoked, actualUser.Status);
}
[Fact]
public async Task BulkRevoke_CannotRevokeSelf_ReturnsError()
{
var (userEmail, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
_organization.Id, OrganizationUserType.Admin);
await _loginHelper.LoginAsync(userEmail);
var request = new OrganizationUserBulkRequestModel
{
Ids = [orgUser.Id]
};
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
var content = await httpResponse.Content.ReadFromJsonAsync<ListResponseModel<OrganizationUserBulkResponseModel>>();
Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode);
Assert.NotNull(content);
Assert.Single(content.Data);
Assert.Contains(content.Data, r => r.Id == orgUser.Id && r.Error == "You cannot revoke yourself.");
var actualUser = await _factory.GetService<IOrganizationUserRepository>().GetByIdAsync(orgUser.Id);
Assert.NotNull(actualUser);
Assert.Equal(OrganizationUserStatusType.Confirmed, actualUser.Status);
}
[Fact]
public async Task BulkRevoke_AlreadyRevoked_ReturnsError()
{
var (ownerEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
_organization.Id, OrganizationUserType.Owner);
await _loginHelper.LoginAsync(ownerEmail);
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User);
var organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
await organizationUserRepository.RevokeAsync(orgUser.Id);
var request = new OrganizationUserBulkRequestModel
{
Ids = [orgUser.Id]
};
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
var content = await httpResponse.Content.ReadFromJsonAsync<ListResponseModel<OrganizationUserBulkResponseModel>>();
Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode);
Assert.NotNull(content);
Assert.Single(content.Data);
Assert.Contains(content.Data, r => r.Id == orgUser.Id && r.Error == "Already revoked.");
var actualUser = await organizationUserRepository.GetByIdAsync(orgUser.Id);
Assert.NotNull(actualUser);
Assert.Equal(OrganizationUserStatusType.Revoked, actualUser.Status);
}
[Fact]
public async Task BulkRevoke_AdminCannotRevokeOwner_ReturnsError()
{
var (adminEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
_organization.Id, OrganizationUserType.Admin);
await _loginHelper.LoginAsync(adminEmail);
var (_, ownerOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.Owner);
var request = new OrganizationUserBulkRequestModel
{
Ids = [ownerOrgUser.Id]
};
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
var content = await httpResponse.Content.ReadFromJsonAsync<ListResponseModel<OrganizationUserBulkResponseModel>>();
Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode);
Assert.NotNull(content);
Assert.Single(content.Data);
Assert.Contains(content.Data, r => r.Id == ownerOrgUser.Id && r.Error == "Only owners can revoke other owners.");
var actualUser = await _factory.GetService<IOrganizationUserRepository>().GetByIdAsync(ownerOrgUser.Id);
Assert.NotNull(actualUser);
Assert.Equal(OrganizationUserStatusType.Confirmed, actualUser.Status);
}
[Fact]
public async Task BulkRevoke_MixedResults()
{
var (ownerEmail, requestingOwner) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
_organization.Id, OrganizationUserType.Owner);
await _loginHelper.LoginAsync(ownerEmail);
var (_, validOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User);
var (_, alreadyRevokedOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User);
var organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
await organizationUserRepository.RevokeAsync(alreadyRevokedOrgUser.Id);
var request = new OrganizationUserBulkRequestModel
{
Ids = [validOrgUser.Id, alreadyRevokedOrgUser.Id, requestingOwner.Id]
};
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
var content = await httpResponse.Content.ReadFromJsonAsync<ListResponseModel<OrganizationUserBulkResponseModel>>();
Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode);
Assert.NotNull(content);
Assert.Equal(3, content.Data.Count());
Assert.Contains(content.Data, r => r.Id == validOrgUser.Id && r.Error == string.Empty);
Assert.Contains(content.Data, r => r.Id == alreadyRevokedOrgUser.Id && r.Error == "Already revoked.");
Assert.Contains(content.Data, r => r.Id == requestingOwner.Id && r.Error == "You cannot revoke yourself.");
var actualUsers = await organizationUserRepository.GetManyAsync([validOrgUser.Id, alreadyRevokedOrgUser.Id, requestingOwner.Id]);
Assert.Equal(OrganizationUserStatusType.Revoked, actualUsers.First(u => u.Id == validOrgUser.Id).Status);
Assert.Equal(OrganizationUserStatusType.Revoked, actualUsers.First(u => u.Id == alreadyRevokedOrgUser.Id).Status);
Assert.Equal(OrganizationUserStatusType.Confirmed, actualUsers.First(u => u.Id == requestingOwner.Id).Status);
}
[Theory]
[InlineData(OrganizationUserType.User)]
[InlineData(OrganizationUserType.Custom)]
public async Task BulkRevoke_WithoutManageUsersPermission_ReturnsForbidden(OrganizationUserType organizationUserType)
{
var (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
_organization.Id, organizationUserType, new Permissions { ManageUsers = false });
await _loginHelper.LoginAsync(userEmail);
var request = new OrganizationUserBulkRequestModel
{
Ids = [Guid.NewGuid()]
};
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
Assert.Equal(HttpStatusCode.Forbidden, httpResponse.StatusCode);
}
[Fact]
public async Task BulkRevoke_WithEmptyIds_ReturnsBadRequest()
{
await _loginHelper.LoginAsync(_ownerEmail);
var request = new OrganizationUserBulkRequestModel
{
Ids = []
};
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
Assert.Equal(HttpStatusCode.BadRequest, httpResponse.StatusCode);
}
[Fact]
public async Task BulkRevoke_WithInvalidOrganizationId_ReturnsForbidden()
{
var (ownerEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
_organization.Id, OrganizationUserType.Owner);
await _loginHelper.LoginAsync(ownerEmail);
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User);
var invalidOrgId = Guid.NewGuid();
var request = new OrganizationUserBulkRequestModel
{
Ids = [orgUser.Id]
};
var httpResponse = await _client.PutAsJsonAsync($"organizations/{invalidOrgId}/users/revoke", request);
Assert.Equal(HttpStatusCode.Forbidden, httpResponse.StatusCode);
}
[Fact]
public async Task BulkRevoke_ProviderRevokesOwner_ReturnsOk()
{
var providerEmail = $"provider-user{Guid.NewGuid()}@example.com";
// create user for provider
await _factory.LoginWithNewAccount(providerEmail);
// create provider and provider user
await _factory.GetService<ICreateProviderCommand>()
.CreateBusinessUnitAsync(
new Provider
{
Name = "provider",
Type = ProviderType.BusinessUnit
},
providerEmail,
PlanType.EnterpriseAnnually2023,
10);
await _loginHelper.LoginAsync(providerEmail);
var providerUserUser = await _factory.GetService<IUserRepository>().GetByEmailAsync(providerEmail);
var providerUserCollection = await _factory.GetService<IProviderUserRepository>()
.GetManyByUserAsync(providerUserUser!.Id);
var providerUser = providerUserCollection.First();
await _factory.GetService<IProviderOrganizationRepository>().CreateAsync(new ProviderOrganization
{
ProviderId = providerUser.ProviderId,
OrganizationId = _organization.Id,
Key = null,
Settings = null
});
var (_, ownerOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
_organization.Id, OrganizationUserType.Owner);
var request = new OrganizationUserBulkRequestModel
{
Ids = [ownerOrgUser.Id]
};
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode);
}
}

View File

@@ -1,39 +1,593 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.Models.Request;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Seeder.Recipes;
using Xunit;
using Xunit.Abstractions;
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
public class OrganizationUsersControllerPerformanceTest(ITestOutputHelper testOutputHelper)
public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testOutputHelper)
{
/// <summary>
/// Tests GET /organizations/{orgId}/users?includeCollections=true
/// </summary>
[Theory(Skip = "Performance test")]
[InlineData(100)]
[InlineData(60000)]
public async Task GetAsync(int seats)
[InlineData(10)]
//[InlineData(100)]
//[InlineData(1000)]
public async Task GetAllUsers_WithCollections(int seats)
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var seeder = new OrganizationWithUsersRecipe(db);
var orgSeeder = new OrganizationWithUsersRecipe(db);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
var orgId = seeder.Seed("Org", seats, "large.test");
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var tokens = await factory.LoginAsync("admin@large.test", "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs=");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token);
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats);
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds);
groupsSeeder.AddToOrganization(orgId, 5, orgUserIds);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.GetAsync($"/organizations/{orgId}/users?includeCollections=true");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadAsStringAsync();
Assert.NotEmpty(result);
stopwatch.Stop();
testOutputHelper.WriteLine($"GET /users - Seats: {seats}; Request duration: {stopwatch.ElapsedMilliseconds} ms");
}
/// <summary>
/// Tests GET /organizations/{orgId}/users/mini-details
/// </summary>
[Theory(Skip = "Performance test")]
[InlineData(10)]
//[InlineData(100)]
//[InlineData(1000)]
public async Task GetAllUsers_MiniDetails(int seats)
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats);
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds);
groupsSeeder.AddToOrganization(orgId, 5, orgUserIds);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.GetAsync($"/organizations/{orgId}/users/mini-details");
stopwatch.Stop();
testOutputHelper.WriteLine($"Seed: {seats}; Request duration: {stopwatch.ElapsedMilliseconds} ms");
testOutputHelper.WriteLine($"GET /users/mini-details - Seats: {seats}; Request duration: {stopwatch.ElapsedMilliseconds} ms");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
/// <summary>
/// Tests GET /organizations/{orgId}/users/{id}?includeGroups=true
/// </summary>
[Fact(Skip = "Performance test")]
public async Task GetSingleUser_WithGroups()
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
var orgUserId = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).FirstOrDefault();
groupsSeeder.AddToOrganization(orgId, 2, [orgUserId]);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.GetAsync($"/organizations/{orgId}/users/{orgUserId}?includeGroups=true");
stopwatch.Stop();
testOutputHelper.WriteLine($"GET /users/{{id}} - Request duration: {stopwatch.ElapsedMilliseconds} ms");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
/// <summary>
/// Tests GET /organizations/{orgId}/users/{id}/reset-password-details
/// </summary>
[Fact(Skip = "Performance test")]
public async Task GetResetPasswordDetails_ForSingleUser()
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
var orgUserId = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).FirstOrDefault();
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.GetAsync($"/organizations/{orgId}/users/{orgUserId}/reset-password-details");
stopwatch.Stop();
testOutputHelper.WriteLine($"GET /users/{{id}}/reset-password-details - Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
/// <summary>
/// Tests POST /organizations/{orgId}/users/confirm
/// </summary>
[Theory(Skip = "Performance test")]
[InlineData(10)]
//[InlineData(100)]
//[InlineData(1000)]
public async Task BulkConfirmUsers(int userCount)
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(
name: "Org",
domain: domain,
users: userCount,
usersStatus: OrganizationUserStatusType.Accepted);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
var acceptedUserIds = db.OrganizationUsers
.Where(ou => ou.OrganizationId == orgId && ou.Status == OrganizationUserStatusType.Accepted)
.Select(ou => ou.Id)
.ToList();
var confirmRequest = new OrganizationUserBulkConfirmRequestModel
{
Keys = acceptedUserIds.Select(id => new OrganizationUserBulkConfirmRequestModelEntry { Id = id, Key = "test-key-" + id }),
DefaultUserCollectionName = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="
};
var requestContent = new StringContent(JsonSerializer.Serialize(confirmRequest), Encoding.UTF8, "application/json");
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.PostAsync($"/organizations/{orgId}/users/confirm", requestContent);
stopwatch.Stop();
testOutputHelper.WriteLine($"POST /users/confirm - Users: {acceptedUserIds.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
Assert.True(response.IsSuccessStatusCode);
}
/// <summary>
/// Tests POST /organizations/{orgId}/users/remove
/// </summary>
[Theory(Skip = "Performance test")]
[InlineData(10)]
//[InlineData(100)]
//[InlineData(1000)]
public async Task BulkRemoveUsers(int userCount)
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
var usersToRemove = db.OrganizationUsers
.Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User)
.Select(ou => ou.Id)
.ToList();
var removeRequest = new OrganizationUserBulkRequestModel { Ids = usersToRemove };
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var requestContent = new StringContent(JsonSerializer.Serialize(removeRequest), Encoding.UTF8, "application/json");
var response = await client.PostAsync($"/organizations/{orgId}/users/remove", requestContent);
stopwatch.Stop();
testOutputHelper.WriteLine($"POST /users/remove - Users: {usersToRemove.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
Assert.True(response.IsSuccessStatusCode);
}
/// <summary>
/// Tests PUT /organizations/{orgId}/users/revoke
/// </summary>
[Theory(Skip = "Performance test")]
[InlineData(10)]
//[InlineData(100)]
//[InlineData(1000)]
public async Task BulkRevokeUsers(int userCount)
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(
name: "Org",
domain: domain,
users: userCount,
usersStatus: OrganizationUserStatusType.Confirmed);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
var usersToRevoke = db.OrganizationUsers
.Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User)
.Select(ou => ou.Id)
.ToList();
var revokeRequest = new OrganizationUserBulkRequestModel { Ids = usersToRevoke };
var requestContent = new StringContent(JsonSerializer.Serialize(revokeRequest), Encoding.UTF8, "application/json");
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.PutAsync($"/organizations/{orgId}/users/revoke", requestContent);
stopwatch.Stop();
testOutputHelper.WriteLine($"PUT /users/revoke - Users: {usersToRevoke.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
Assert.True(response.IsSuccessStatusCode);
}
/// <summary>
/// Tests PUT /organizations/{orgId}/users/restore
/// </summary>
[Theory(Skip = "Performance test")]
[InlineData(10)]
//[InlineData(100)]
//[InlineData(1000)]
public async Task BulkRestoreUsers(int userCount)
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(
name: "Org",
domain: domain,
users: userCount,
usersStatus: OrganizationUserStatusType.Revoked);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
var usersToRestore = db.OrganizationUsers
.Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User)
.Select(ou => ou.Id)
.ToList();
var restoreRequest = new OrganizationUserBulkRequestModel { Ids = usersToRestore };
var requestContent = new StringContent(JsonSerializer.Serialize(restoreRequest), Encoding.UTF8, "application/json");
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.PutAsync($"/organizations/{orgId}/users/restore", requestContent);
stopwatch.Stop();
testOutputHelper.WriteLine($"PUT /users/restore - Users: {usersToRestore.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
Assert.True(response.IsSuccessStatusCode);
}
/// <summary>
/// Tests POST /organizations/{orgId}/users/delete-account
/// </summary>
[Theory(Skip = "Performance test")]
[InlineData(10)]
//[InlineData(100)]
//[InlineData(1000)]
public async Task BulkDeleteAccounts(int userCount)
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domainSeeder = new OrganizationDomainRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(
name: "Org",
domain: domain,
users: userCount,
usersStatus: OrganizationUserStatusType.Confirmed);
domainSeeder.AddVerifiedDomainToOrganization(orgId, domain);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
var usersToDelete = db.OrganizationUsers
.Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User)
.Select(ou => ou.Id)
.ToList();
var deleteRequest = new OrganizationUserBulkRequestModel { Ids = usersToDelete };
var requestContent = new StringContent(JsonSerializer.Serialize(deleteRequest), Encoding.UTF8, "application/json");
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.PostAsync($"/organizations/{orgId}/users/delete-account", requestContent);
stopwatch.Stop();
testOutputHelper.WriteLine($"POST /users/delete-account - Users: {usersToDelete.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
Assert.True(response.IsSuccessStatusCode);
}
/// <summary>
/// Tests PUT /organizations/{orgId}/users/{id}
/// </summary>
[Fact(Skip = "Performance test")]
public async Task UpdateSingleUser_WithCollectionsAndGroups()
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
var collectionIds = collectionsSeeder.AddToOrganization(orgId, 3, orgUserIds, 0);
var groupIds = groupsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
var userToUpdate = db.OrganizationUsers
.FirstOrDefault(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User);
var updateRequest = new OrganizationUserUpdateRequestModel
{
Type = OrganizationUserType.Custom,
Collections = collectionIds.Select(c => new SelectionReadOnlyRequestModel { Id = c, ReadOnly = false, HidePasswords = false, Manage = false }),
Groups = groupIds,
AccessSecretsManager = false,
Permissions = new Permissions { AccessEventLogs = true }
};
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.PutAsync($"/organizations/{orgId}/users/{userToUpdate.Id}",
new StringContent(JsonSerializer.Serialize(updateRequest), Encoding.UTF8, "application/json"));
stopwatch.Stop();
testOutputHelper.WriteLine($"PUT /users/{{id}} - Collections: {collectionIds.Count}; Groups: {groupIds.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
Assert.True(response.IsSuccessStatusCode);
}
/// <summary>
/// Tests PUT /organizations/{orgId}/users/enable-secrets-manager
/// </summary>
[Theory(Skip = "Performance test")]
[InlineData(10)]
//[InlineData(100)]
//[InlineData(1000)]
public async Task BulkEnableSecretsManager(int userCount)
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
var usersToEnable = db.OrganizationUsers
.Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User)
.Select(ou => ou.Id)
.ToList();
var enableRequest = new OrganizationUserBulkRequestModel { Ids = usersToEnable };
var requestContent = new StringContent(JsonSerializer.Serialize(enableRequest), Encoding.UTF8, "application/json");
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.PutAsync($"/organizations/{orgId}/users/enable-secrets-manager", requestContent);
stopwatch.Stop();
testOutputHelper.WriteLine($"PUT /users/enable-secrets-manager - Users: {usersToEnable.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
Assert.True(response.IsSuccessStatusCode);
}
/// <summary>
/// Tests DELETE /organizations/{orgId}/users/{id}/delete-account
/// </summary>
[Fact(Skip = "Performance test")]
public async Task DeleteSingleUserAccount_FromVerifiedDomain()
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domainSeeder = new OrganizationDomainRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(
name: "Org",
domain: domain,
users: 2,
usersStatus: OrganizationUserStatusType.Confirmed);
domainSeeder.AddVerifiedDomainToOrganization(orgId, domain);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
var userToDelete = db.OrganizationUsers
.FirstOrDefault(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User);
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.DeleteAsync($"/organizations/{orgId}/users/{userToDelete.Id}/delete-account");
stopwatch.Stop();
testOutputHelper.WriteLine($"DELETE /users/{{id}}/delete-account - Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
/// <summary>
/// Tests POST /organizations/{orgId}/users/invite
/// </summary>
[Theory(Skip = "Performance test")]
[InlineData(1)]
//[InlineData(5)]
//[InlineData(20)]
public async Task InviteUsers(int emailCount)
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var collectionsSeeder = new CollectionsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
var collectionIds = collectionsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
var emails = Enumerable.Range(0, emailCount).Select(i => $"{i:D4}@{domain}").ToArray();
var inviteRequest = new OrganizationUserInviteRequestModel
{
Emails = emails,
Type = OrganizationUserType.User,
AccessSecretsManager = false,
Collections = Array.Empty<SelectionReadOnlyRequestModel>(),
Groups = Array.Empty<Guid>(),
Permissions = null
};
var requestContent = new StringContent(JsonSerializer.Serialize(inviteRequest), Encoding.UTF8, "application/json");
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.PostAsync($"/organizations/{orgId}/users/invite", requestContent);
stopwatch.Stop();
testOutputHelper.WriteLine($"POST /users/invite - Emails: {emails.Length}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
/// <summary>
/// Tests POST /organizations/{orgId}/users/reinvite
/// </summary>
[Theory(Skip = "Performance test")]
[InlineData(10)]
//[InlineData(100)]
//[InlineData(1000)]
public async Task BulkReinviteUsers(int userCount)
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(
name: "Org",
domain: domain,
users: userCount,
usersStatus: OrganizationUserStatusType.Invited);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
var usersToReinvite = db.OrganizationUsers
.Where(ou => ou.OrganizationId == orgId && ou.Status == OrganizationUserStatusType.Invited)
.Select(ou => ou.Id)
.ToList();
var reinviteRequest = new OrganizationUserBulkRequestModel { Ids = usersToReinvite };
var requestContent = new StringContent(JsonSerializer.Serialize(reinviteRequest), Encoding.UTF8, "application/json");
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.PostAsync($"/organizations/{orgId}/users/reinvite", requestContent);
stopwatch.Stop();
testOutputHelper.WriteLine($"POST /users/reinvite - Users: {usersToReinvite.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
Assert.True(response.IsSuccessStatusCode);
}
}

View File

@@ -3,7 +3,6 @@ using Bit.Api.AdminConsole.Authorization;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.Models.Request.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums;
@@ -14,8 +13,6 @@ using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Repositories;
using Bit.Core.Services;
using NSubstitute;
using Xunit;
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
@@ -32,12 +29,6 @@ public class OrganizationUsersControllerPutResetPasswordTests : IClassFixture<Ap
public OrganizationUsersControllerPutResetPasswordTests(ApiApplicationFactory apiFactory)
{
_factory = apiFactory;
_factory.SubstituteService<IFeatureService>(featureService =>
{
featureService
.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand)
.Returns(true);
});
_client = _factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
}

View File

@@ -0,0 +1,163 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.Billing.Enums;
using Bit.Core.Tokens;
using Bit.Seeder.Recipes;
using Xunit;
using Xunit.Abstractions;
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutputHelper)
{
/// <summary>
/// Tests DELETE /organizations/{id} with password verification
/// </summary>
[Theory(Skip = "Performance test")]
[InlineData(10, 5, 3)]
//[InlineData(100, 20, 10)]
//[InlineData(1000, 50, 25)]
public async Task DeleteOrganization_WithPasswordVerification(int userCount, int collectionCount, int groupCount)
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0);
groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
var deleteRequest = new SecretVerificationRequestModel
{
MasterPasswordHash = "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs="
};
var request = new HttpRequestMessage(HttpMethod.Delete, $"/organizations/{orgId}")
{
Content = new StringContent(JsonSerializer.Serialize(deleteRequest), Encoding.UTF8, "application/json")
};
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.SendAsync(request);
stopwatch.Stop();
testOutputHelper.WriteLine($"DELETE /organizations/{{id}} - Users: {userCount}; Collections: {collectionCount}; Groups: {groupCount}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
/// <summary>
/// Tests POST /organizations/{id}/delete-recover-token with token verification
/// </summary>
[Theory(Skip = "Performance test")]
[InlineData(10, 5, 3)]
//[InlineData(100, 20, 10)]
//[InlineData(1000, 50, 25)]
public async Task DeleteOrganization_WithTokenVerification(int userCount, int collectionCount, int groupCount)
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0);
groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
var organization = db.Organizations.FirstOrDefault(o => o.Id == orgId);
Assert.NotNull(organization);
var tokenFactory = factory.GetService<IDataProtectorTokenFactory<OrgDeleteTokenable>>();
var tokenable = new OrgDeleteTokenable(organization, 24);
var token = tokenFactory.Protect(tokenable);
var deleteRequest = new OrganizationVerifyDeleteRecoverRequestModel
{
Token = token
};
var requestContent = new StringContent(JsonSerializer.Serialize(deleteRequest), Encoding.UTF8, "application/json");
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.PostAsync($"/organizations/{orgId}/delete-recover-token", requestContent);
stopwatch.Stop();
testOutputHelper.WriteLine($"POST /organizations/{{id}}/delete-recover-token - Users: {userCount}; Collections: {collectionCount}; Groups: {groupCount}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
/// <summary>
/// Tests POST /organizations/create-without-payment
/// </summary>
[Fact(Skip = "Performance test")]
public async Task CreateOrganization_WithoutPayment()
{
await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient();
var email = $"user@{OrganizationTestHelpers.GenerateRandomDomain()}";
var masterPasswordHash = "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs=";
await factory.LoginWithNewAccount(email, masterPasswordHash);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, email, masterPasswordHash);
var createRequest = new OrganizationNoPaymentCreateRequest
{
Name = "Test Organization",
BusinessName = "Test Business Name",
BillingEmail = email,
PlanType = PlanType.EnterpriseAnnually,
Key = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=",
AdditionalSeats = 1,
AdditionalStorageGb = 1,
UseSecretsManager = true,
AdditionalSmSeats = 1,
AdditionalServiceAccounts = 2,
MaxAutoscaleSeats = 100,
PremiumAccessAddon = false,
CollectionName = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="
};
var requestContent = new StringContent(JsonSerializer.Serialize(createRequest), Encoding.UTF8, "application/json");
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await client.PostAsync("/organizations/create-without-payment", requestContent);
stopwatch.Stop();
testOutputHelper.WriteLine($"POST /organizations/create-without-payment - AdditionalSeats: {createRequest.AdditionalSeats}; AdditionalStorageGb: {createRequest.AdditionalStorageGb}; AdditionalSmSeats: {createRequest.AdditionalSmSeats}; AdditionalServiceAccounts: {createRequest.AdditionalServiceAccounts}; MaxAutoscaleSeats: {createRequest.MaxAutoscaleSeats}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}

View File

@@ -192,6 +192,15 @@ public static class OrganizationTestHelpers
await policyRepository.CreateAsync(policy);
}
/// <summary>
/// Generates a unique random domain name for testing purposes.
/// </summary>
/// <returns>A domain string like "a1b2c3d4.com"</returns>
public static string GenerateRandomDomain()
{
return $"{Guid.NewGuid().ToString("N").Substring(0, 8)}.com";
}
/// <summary>
/// Creates a user account without a Master Password and adds them as a member to the specified organization.
/// </summary>

View File

@@ -0,0 +1,32 @@
using System.Net.Http.Headers;
using Bit.Api.IntegrationTest.Factories;
namespace Bit.Api.IntegrationTest.Helpers;
/// <summary>
/// Helper methods for performance tests to reduce code duplication.
/// </summary>
public static class PerformanceTestHelpers
{
/// <summary>
/// Standard password hash used across performance tests.
/// </summary>
public const string StandardPasswordHash = "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs=";
/// <summary>
/// Authenticates an HttpClient with a bearer token for the specified user.
/// </summary>
/// <param name="factory">The application factory to use for login.</param>
/// <param name="client">The HttpClient to authenticate.</param>
/// <param name="email">The user's email address.</param>
/// <param name="masterPasswordHash">The user's master password hash. Defaults to StandardPasswordHash.</param>
public static async Task AuthenticateClientAsync(
SqlServerApiApplicationFactory factory,
HttpClient client,
string email,
string? masterPasswordHash = null)
{
var tokens = await factory.LoginAsync(email, masterPasswordHash ?? StandardPasswordHash);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token);
}
}

View File

@@ -0,0 +1,289 @@
using System.Net;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.SecretsManager.Enums;
using Bit.Api.IntegrationTest.SecretsManager.Helpers;
using Bit.Api.Models.Response;
using Bit.Api.SecretsManager.Models.Request;
using Bit.Api.SecretsManager.Models.Response;
using Bit.Core.Enums;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
using Xunit;
namespace Bit.Api.IntegrationTest.SecretsManager.Controllers;
public class SecretVersionsControllerTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
{
private readonly string _mockEncryptedString =
"2.3Uk+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98sp4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg=";
private readonly HttpClient _client;
private readonly ApiApplicationFactory _factory;
private readonly ISecretRepository _secretRepository;
private readonly ISecretVersionRepository _secretVersionRepository;
private readonly IAccessPolicyRepository _accessPolicyRepository;
private readonly LoginHelper _loginHelper;
private string _email = null!;
private SecretsManagerOrganizationHelper _organizationHelper = null!;
public SecretVersionsControllerTests(ApiApplicationFactory factory)
{
_factory = factory;
_client = _factory.CreateClient();
_secretRepository = _factory.GetService<ISecretRepository>();
_secretVersionRepository = _factory.GetService<ISecretVersionRepository>();
_accessPolicyRepository = _factory.GetService<IAccessPolicyRepository>();
_loginHelper = new LoginHelper(_factory, _client);
}
public async Task InitializeAsync()
{
_email = $"integration-test{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(_email);
_organizationHelper = new SecretsManagerOrganizationHelper(_factory, _email);
}
public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}
[Theory]
[InlineData(false, false, false)]
[InlineData(false, false, true)]
[InlineData(false, true, false)]
[InlineData(false, true, true)]
[InlineData(true, false, false)]
[InlineData(true, false, true)]
[InlineData(true, true, false)]
public async Task GetVersionsBySecretId_SmAccessDenied_NotFound(bool useSecrets, bool accessSecrets, bool organizationEnabled)
{
var (org, _) = await _organizationHelper.Initialize(useSecrets, accessSecrets, organizationEnabled);
await _loginHelper.LoginAsync(_email);
var secret = await _secretRepository.CreateAsync(new Secret
{
OrganizationId = org.Id,
Key = _mockEncryptedString,
Value = _mockEncryptedString,
Note = _mockEncryptedString
});
var response = await _client.GetAsync($"/secrets/{secret.Id}/versions");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Theory]
[InlineData(PermissionType.RunAsAdmin)]
[InlineData(PermissionType.RunAsUserWithPermission)]
public async Task GetVersionsBySecretId_Success(PermissionType permissionType)
{
var (org, _) = await _organizationHelper.Initialize(true, true, true);
await _loginHelper.LoginAsync(_email);
var secret = await _secretRepository.CreateAsync(new Secret
{
OrganizationId = org.Id,
Key = _mockEncryptedString,
Value = _mockEncryptedString,
Note = _mockEncryptedString
});
// Create some versions
var version1 = await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = _mockEncryptedString,
VersionDate = DateTime.UtcNow.AddDays(-2)
});
var version2 = await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = _mockEncryptedString,
VersionDate = DateTime.UtcNow.AddDays(-1)
});
if (permissionType == PermissionType.RunAsUserWithPermission)
{
var (email, orgUser) = await _organizationHelper.CreateNewUser(OrganizationUserType.User, true);
await _loginHelper.LoginAsync(email);
var accessPolicies = new List<BaseAccessPolicy>
{
new UserSecretAccessPolicy
{
GrantedSecretId = secret.Id,
OrganizationUserId = orgUser.Id,
Read = true,
Write = true
}
};
await _accessPolicyRepository.CreateManyAsync(accessPolicies);
}
var response = await _client.GetAsync($"/secrets/{secret.Id}/versions");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ListResponseModel<SecretVersionResponseModel>>();
Assert.NotNull(result);
Assert.Equal(2, result.Data.Count());
}
[Fact]
public async Task GetVersionById_Success()
{
var (org, _) = await _organizationHelper.Initialize(true, true, true);
await _loginHelper.LoginAsync(_email);
var secret = await _secretRepository.CreateAsync(new Secret
{
OrganizationId = org.Id,
Key = _mockEncryptedString,
Value = _mockEncryptedString,
Note = _mockEncryptedString
});
var version = await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = _mockEncryptedString,
VersionDate = DateTime.UtcNow
});
var response = await _client.GetAsync($"/secret-versions/{version.Id}");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<SecretVersionResponseModel>();
Assert.NotNull(result);
Assert.Equal(version.Id, result.Id);
Assert.Equal(secret.Id, result.SecretId);
}
[Fact]
public async Task RestoreVersion_Success()
{
var (org, _) = await _organizationHelper.Initialize(true, true, true);
await _loginHelper.LoginAsync(_email);
var secret = await _secretRepository.CreateAsync(new Secret
{
OrganizationId = org.Id,
Key = _mockEncryptedString,
Value = "OriginalValue",
Note = _mockEncryptedString
});
var version = await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = "OldValue",
VersionDate = DateTime.UtcNow.AddDays(-1)
});
var request = new RestoreSecretVersionRequestModel
{
VersionId = version.Id
};
var response = await _client.PutAsJsonAsync($"/secrets/{secret.Id}/versions/restore", request);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<SecretResponseModel>();
Assert.NotNull(result);
Assert.Equal("OldValue", result.Value);
}
[Fact]
public async Task BulkDelete_Success()
{
var (org, _) = await _organizationHelper.Initialize(true, true, true);
await _loginHelper.LoginAsync(_email);
var secret = await _secretRepository.CreateAsync(new Secret
{
OrganizationId = org.Id,
Key = _mockEncryptedString,
Value = _mockEncryptedString,
Note = _mockEncryptedString
});
var version1 = await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = _mockEncryptedString,
VersionDate = DateTime.UtcNow.AddDays(-2)
});
var version2 = await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = _mockEncryptedString,
VersionDate = DateTime.UtcNow.AddDays(-1)
});
var ids = new List<Guid> { version1.Id, version2.Id };
var response = await _client.PostAsJsonAsync("/secret-versions/delete", ids);
response.EnsureSuccessStatusCode();
var versions = await _secretVersionRepository.GetManyBySecretIdAsync(secret.Id);
Assert.Empty(versions);
}
[Fact]
public async Task GetVersionsBySecretId_ReturnsOrderedByVersionDate()
{
var (org, _) = await _organizationHelper.Initialize(true, true, true);
await _loginHelper.LoginAsync(_email);
var secret = await _secretRepository.CreateAsync(new Secret
{
OrganizationId = org.Id,
Key = _mockEncryptedString,
Value = _mockEncryptedString,
Note = _mockEncryptedString
});
// Create versions in random order
await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = "Version2",
VersionDate = DateTime.UtcNow.AddDays(-1)
});
await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = "Version3",
VersionDate = DateTime.UtcNow
});
await _secretVersionRepository.CreateAsync(new SecretVersion
{
SecretId = secret.Id,
Value = "Version1",
VersionDate = DateTime.UtcNow.AddDays(-2)
});
var response = await _client.GetAsync($"/secrets/{secret.Id}/versions");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ListResponseModel<SecretVersionResponseModel>>();
Assert.NotNull(result);
Assert.Equal(3, result.Data.Count());
var versions = result.Data.ToList();
// Should be ordered by VersionDate descending (newest first)
Assert.Equal("Version3", versions[0].Value);
Assert.Equal("Version2", versions[1].Value);
Assert.Equal("Version1", versions[2].Value);
}
}

View File

@@ -2,15 +2,14 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Controllers;
@@ -19,7 +18,7 @@ namespace Bit.Api.Test.AdminConsole.Controllers;
[SutProviderCustomize]
public class OrganizationIntegrationControllerTests
{
private OrganizationIntegrationRequestModel _webhookRequestModel = new OrganizationIntegrationRequestModel()
private readonly OrganizationIntegrationRequestModel _webhookRequestModel = new()
{
Configuration = null,
Type = IntegrationType.Webhook
@@ -48,13 +47,13 @@ public class OrganizationIntegrationControllerTests
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
sutProvider.GetDependency<IGetOrganizationIntegrationsQuery>()
.GetManyByOrganizationAsync(organizationId)
.Returns(integrations);
var result = await sutProvider.Sut.GetAsync(organizationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
await sutProvider.GetDependency<IGetOrganizationIntegrationsQuery>().Received(1)
.GetManyByOrganizationAsync(organizationId);
Assert.Equal(integrations.Count, result.Count);
@@ -70,7 +69,7 @@ public class OrganizationIntegrationControllerTests
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
sutProvider.GetDependency<IGetOrganizationIntegrationsQuery>()
.GetManyByOrganizationAsync(organizationId)
.Returns([]);
@@ -80,199 +79,133 @@ public class OrganizationIntegrationControllerTests
}
[Theory, BitAutoData]
public async Task CreateAsync_Webhook_AllParamsProvided_Succeeds(
public async Task CreateAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration integration)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<ICreateOrganizationIntegrationCommand>()
.CreateAsync(Arg.Any<OrganizationIntegration>())
.Returns(integration);
var response = await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel);
await sutProvider.GetDependency<ICreateOrganizationIntegrationCommand>().Received(1)
.CreateAsync(Arg.Is<OrganizationIntegration>(i =>
i.OrganizationId == organizationId &&
i.Type == IntegrationType.Webhook));
Assert.IsType<OrganizationIntegrationResponseModel>(response);
}
[Theory, BitAutoData]
public async Task CreateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegration>())
.Returns(callInfo => callInfo.Arg<OrganizationIntegration>());
var response = await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.CreateAsync(Arg.Any<OrganizationIntegration>());
Assert.IsType<OrganizationIntegrationResponseModel>(response);
Assert.Equal(IntegrationType.Webhook, response.Type);
}
[Theory, BitAutoData]
public async Task CreateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider<OrganizationIntegrationController> sutProvider, Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel));
await Assert.ThrowsAsync<NotFoundException>(async () =>
await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel));
}
[Theory, BitAutoData]
public async Task DeleteAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
Guid integrationId)
{
organizationIntegration.OrganizationId = organizationId;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id);
await sutProvider.Sut.DeleteAsync(organizationId, integrationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.DeleteAsync(organizationIntegration);
await sutProvider.GetDependency<IDeleteOrganizationIntegrationCommand>().Received(1)
.DeleteAsync(organizationId, integrationId);
}
[Theory, BitAutoData]
[Obsolete("Obsolete")]
public async Task PostDeleteAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
organizationIntegration.OrganizationId = organizationId;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
await sutProvider.Sut.PostDeleteAsync(organizationId, organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.DeleteAsync(organizationIntegration);
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
organizationIntegration.OrganizationId = Guid.NewGuid();
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty));
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
Guid integrationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty));
await sutProvider.Sut.PostDeleteAsync(organizationId, integrationId);
await sutProvider.GetDependency<IDeleteOrganizationIntegrationCommand>().Received(1)
.DeleteAsync(organizationId, integrationId);
}
[Theory, BitAutoData]
public async Task DeleteAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
Guid organizationId,
Guid integrationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty));
await Assert.ThrowsAsync<NotFoundException>(async () =>
await sutProvider.Sut.DeleteAsync(organizationId, integrationId));
}
[Theory, BitAutoData]
public async Task UpdateAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
Guid integrationId,
OrganizationIntegration integration)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegration.Type = IntegrationType.Webhook;
integration.OrganizationId = organizationId;
integration.Id = integrationId;
integration.Type = IntegrationType.Webhook;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IUpdateOrganizationIntegrationCommand>()
.UpdateAsync(organizationId, integrationId, Arg.Any<OrganizationIntegration>())
.Returns(integration);
var response = await sutProvider.Sut.UpdateAsync(organizationId, organizationIntegration.Id, _webhookRequestModel);
var response = await sutProvider.Sut.UpdateAsync(organizationId, integrationId, _webhookRequestModel);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.ReplaceAsync(organizationIntegration);
await sutProvider.GetDependency<IUpdateOrganizationIntegrationCommand>().Received(1)
.UpdateAsync(organizationId, integrationId, Arg.Is<OrganizationIntegration>(i =>
i.OrganizationId == organizationId &&
i.Type == IntegrationType.Webhook));
Assert.IsType<OrganizationIntegrationResponseModel>(response);
Assert.Equal(IntegrationType.Webhook, response.Type);
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
organizationIntegration.OrganizationId = Guid.NewGuid();
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.UpdateAsync(organizationId, Guid.Empty, _webhookRequestModel));
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.UpdateAsync(organizationId, Guid.Empty, _webhookRequestModel));
}
[Theory, BitAutoData]
public async Task UpdateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
Guid organizationId,
Guid integrationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.UpdateAsync(organizationId, Guid.Empty, _webhookRequestModel));
await Assert.ThrowsAsync<NotFoundException>(async () =>
await sutProvider.Sut.UpdateAsync(organizationId, integrationId, _webhookRequestModel));
}
}

View File

@@ -11,6 +11,7 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
@@ -452,60 +453,10 @@ public class OrganizationUsersControllerTests
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagDisabled_CallsLegacyPath(
public async Task PutResetPassword_WhenOrganizationUserNotFound_ReturnsNotFound(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(orgId).Returns(true);
sutProvider.GetDependency<IUserService>().AdminResetPasswordAsync(Arg.Any<OrganizationUserType>(), orgId, orgUserId, model.NewMasterPasswordHash, model.Key)
.Returns(Microsoft.AspNetCore.Identity.IdentityResult.Success);
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<Ok>(result);
await sutProvider.GetDependency<IUserService>().Received(1)
.AdminResetPasswordAsync(OrganizationUserType.Owner, orgId, orgUserId, model.NewMasterPasswordHash, model.Key);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagDisabled_WhenOrgUserTypeIsNull_ReturnsNotFound(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(orgId).Returns(false);
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns(new List<CurrentContextOrganization>());
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<Microsoft.AspNetCore.Http.HttpResults.NotFound>(result);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagDisabled_WhenAdminResetPasswordFails_ReturnsBadRequest(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(orgId).Returns(true);
sutProvider.GetDependency<IUserService>().AdminResetPasswordAsync(Arg.Any<OrganizationUserType>(), orgId, orgUserId, model.NewMasterPasswordHash, model.Key)
.Returns(Microsoft.AspNetCore.Identity.IdentityResult.Failed(new Microsoft.AspNetCore.Identity.IdentityError { Description = "Error 1" }));
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<BadRequest<ModelStateDictionary>>(result);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenOrganizationUserNotFound_ReturnsNotFound(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns((OrganizationUser)null);
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
@@ -515,12 +466,11 @@ public class OrganizationUsersControllerTests
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenOrganizationIdMismatch_ReturnsNotFound(
public async Task PutResetPassword_WhenOrganizationIdMismatch_ReturnsNotFound(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser,
SutProvider<OrganizationUsersController> sutProvider)
{
organizationUser.OrganizationId = Guid.NewGuid();
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(organizationUser);
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
@@ -530,12 +480,11 @@ public class OrganizationUsersControllerTests
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenAuthorizationFails_ReturnsBadRequest(
public async Task PutResetPassword_WhenAuthorizationFails_ReturnsBadRequest(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser,
SutProvider<OrganizationUsersController> sutProvider)
{
organizationUser.OrganizationId = orgId;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(organizationUser);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(
@@ -551,12 +500,11 @@ public class OrganizationUsersControllerTests
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenRecoverAccountSucceeds_ReturnsOk(
public async Task PutResetPassword_WhenRecoverAccountSucceeds_ReturnsOk(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser,
SutProvider<OrganizationUsersController> sutProvider)
{
organizationUser.OrganizationId = orgId;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(organizationUser);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(
@@ -577,12 +525,11 @@ public class OrganizationUsersControllerTests
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenRecoverAccountFails_ReturnsBadRequest(
public async Task PutResetPassword_WhenRecoverAccountFails_ReturnsBadRequest(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser,
SutProvider<OrganizationUsersController> sutProvider)
{
organizationUser.OrganizationId = orgId;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(organizationUser);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(
@@ -784,4 +731,68 @@ public class OrganizationUsersControllerTests
var problemResult = Assert.IsType<JsonHttpResult<ErrorResponseModel>>(result);
Assert.Equal(StatusCodes.Status500InternalServerError, problemResult.StatusCode);
}
[Theory]
[BitAutoData]
public async Task BulkReinvite_WhenFeatureFlagEnabled_UsesBulkResendOrganizationInvitesCommand(
Guid organizationId,
OrganizationUserBulkRequestModel bulkRequestModel,
List<OrganizationUser> organizationUsers,
Guid userId,
SutProvider<OrganizationUsersController> sutProvider)
{
// Arrange
sutProvider.GetDependency<ICurrentContext>().ManageUsers(organizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud)
.Returns(true);
var expectedResults = organizationUsers.Select(u => Tuple.Create(u, "")).ToList();
sutProvider.GetDependency<IBulkResendOrganizationInvitesCommand>()
.BulkResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids)
.Returns(expectedResults);
// Act
var response = await sutProvider.Sut.BulkReinvite(organizationId, bulkRequestModel);
// Assert
Assert.Equal(organizationUsers.Count, response.Data.Count());
await sutProvider.GetDependency<IBulkResendOrganizationInvitesCommand>()
.Received(1)
.BulkResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids);
}
[Theory]
[BitAutoData]
public async Task BulkReinvite_WhenFeatureFlagDisabled_UsesLegacyOrganizationService(
Guid organizationId,
OrganizationUserBulkRequestModel bulkRequestModel,
List<OrganizationUser> organizationUsers,
Guid userId,
SutProvider<OrganizationUsersController> sutProvider)
{
// Arrange
sutProvider.GetDependency<ICurrentContext>().ManageUsers(organizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud)
.Returns(false);
var expectedResults = organizationUsers.Select(u => Tuple.Create(u, "")).ToList();
sutProvider.GetDependency<IOrganizationService>()
.ResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids)
.Returns(expectedResults);
// Act
var response = await sutProvider.Sut.BulkReinvite(organizationId, bulkRequestModel);
// Assert
Assert.Equal(organizationUsers.Count, response.Data.Count());
await sutProvider.GetDependency<IOrganizationService>()
.Received(1)
.ResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids);
}
}

View File

@@ -1,14 +1,11 @@
using Bit.Api.AdminConsole.Public.Controllers;
using Bit.Api.AdminConsole.Public.Models.Request;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.Context;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
@@ -22,7 +19,7 @@ public class PoliciesControllerTests
{
[Theory]
[BitAutoData]
public async Task Put_WhenPolicyValidatorsRefactorEnabled_UsesVNextSavePolicyCommand(
public async Task Put_UsesVNextSavePolicyCommand(
Guid organizationId,
PolicyType policyType,
PolicyUpdateRequestModel model,
@@ -33,9 +30,6 @@ public class PoliciesControllerTests
policy.Data = null;
sutProvider.GetDependency<ICurrentContext>()
.OrganizationId.Returns(organizationId);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)
.Returns(true);
sutProvider.GetDependency<IVNextSavePolicyCommand>()
.SaveAsync(Arg.Any<SavePolicyModel>())
.Returns(policy);
@@ -52,36 +46,4 @@ public class PoliciesControllerTests
m.PolicyUpdate.Enabled == model.Enabled.GetValueOrDefault() &&
m.PerformedBy is SystemUser));
}
[Theory]
[BitAutoData]
public async Task Put_WhenPolicyValidatorsRefactorDisabled_UsesLegacySavePolicyCommand(
Guid organizationId,
PolicyType policyType,
PolicyUpdateRequestModel model,
Policy policy,
SutProvider<PoliciesController> sutProvider)
{
// Arrange
policy.Data = null;
sutProvider.GetDependency<ICurrentContext>()
.OrganizationId.Returns(organizationId);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)
.Returns(false);
sutProvider.GetDependency<ISavePolicyCommand>()
.SaveAsync(Arg.Any<PolicyUpdate>())
.Returns(policy);
// Act
await sutProvider.Sut.Put(policyType, model);
// Assert
await sutProvider.GetDependency<ISavePolicyCommand>()
.Received(1)
.SaveAsync(Arg.Is<PolicyUpdate>(p =>
p.OrganizationId == organizationId &&
p.Type == policyType &&
p.Enabled == model.Enabled));
}
}

View File

@@ -4,6 +4,7 @@ using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Queries.Interfaces;
@@ -30,6 +31,7 @@ public class AccountsControllerTests : IDisposable
private readonly IPaymentService _paymentService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
private readonly ILicensingService _licensingService;
private readonly GlobalSettings _globalSettings;
private readonly AccountsController _sut;
@@ -40,13 +42,15 @@ public class AccountsControllerTests : IDisposable
_paymentService = Substitute.For<IPaymentService>();
_twoFactorIsEnabledQuery = Substitute.For<ITwoFactorIsEnabledQuery>();
_userAccountKeysQuery = Substitute.For<IUserAccountKeysQuery>();
_licensingService = Substitute.For<ILicensingService>();
_globalSettings = new GlobalSettings { SelfHosted = false };
_sut = new AccountsController(
_userService,
_twoFactorIsEnabledQuery,
_userAccountKeysQuery,
_featureService
_featureService,
_licensingService
);
}

View File

@@ -3,7 +3,6 @@ using System.Text.Json;
using Bit.Api.AdminConsole.Controllers;
using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
@@ -291,7 +290,7 @@ public class PoliciesControllerTests
string token,
string email,
Organization organization
)
)
{
// Arrange
organization.UsePolicies = true;
@@ -302,14 +301,15 @@ public class PoliciesControllerTests
var decryptedToken = Substitute.For<OrgUserInviteTokenable>();
decryptedToken.Valid.Returns(false);
var orgUserInviteTokenDataFactory = sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>();
var orgUserInviteTokenDataFactory =
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>();
orgUserInviteTokenDataFactory.TryUnprotect(token, out Arg.Any<OrgUserInviteTokenable>())
.Returns(x =>
{
x[1] = decryptedToken;
return true;
});
{
x[1] = decryptedToken;
return true;
});
// Act & Assert
await Assert.ThrowsAsync<NotFoundException>(() =>
@@ -325,7 +325,7 @@ public class PoliciesControllerTests
string token,
string email,
Organization organization
)
)
{
// Arrange
organization.UsePolicies = true;
@@ -338,14 +338,15 @@ public class PoliciesControllerTests
decryptedToken.OrgUserId = organizationUserId;
decryptedToken.OrgUserEmail = email;
var orgUserInviteTokenDataFactory = sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>();
var orgUserInviteTokenDataFactory =
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>();
orgUserInviteTokenDataFactory.TryUnprotect(token, out Arg.Any<OrgUserInviteTokenable>())
.Returns(x =>
{
x[1] = decryptedToken;
return true;
});
{
x[1] = decryptedToken;
return true;
});
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(organizationUserId)
@@ -366,7 +367,7 @@ public class PoliciesControllerTests
string email,
OrganizationUser orgUser,
Organization organization
)
)
{
// Arrange
organization.UsePolicies = true;
@@ -379,14 +380,15 @@ public class PoliciesControllerTests
decryptedToken.OrgUserId = organizationUserId;
decryptedToken.OrgUserEmail = email;
var orgUserInviteTokenDataFactory = sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>();
var orgUserInviteTokenDataFactory =
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>();
orgUserInviteTokenDataFactory.TryUnprotect(token, out Arg.Any<OrgUserInviteTokenable>())
.Returns(x =>
{
x[1] = decryptedToken;
return true;
});
{
x[1] = decryptedToken;
return true;
});
orgUser.OrganizationId = Guid.Empty;
@@ -409,7 +411,7 @@ public class PoliciesControllerTests
string email,
OrganizationUser orgUser,
Organization organization
)
)
{
// Arrange
organization.UsePolicies = true;
@@ -422,14 +424,15 @@ public class PoliciesControllerTests
decryptedToken.OrgUserId = organizationUserId;
decryptedToken.OrgUserEmail = email;
var orgUserInviteTokenDataFactory = sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>();
var orgUserInviteTokenDataFactory =
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>();
orgUserInviteTokenDataFactory.TryUnprotect(token, out Arg.Any<OrgUserInviteTokenable>())
.Returns(x =>
{
x[1] = decryptedToken;
return true;
});
{
x[1] = decryptedToken;
return true;
});
orgUser.OrganizationId = orgId;
sutProvider.GetDependency<IOrganizationUserRepository>()
@@ -463,7 +466,7 @@ public class PoliciesControllerTests
[Theory]
[BitAutoData]
public async Task PutVNext_WhenPolicyValidatorsRefactorEnabled_UsesVNextSavePolicyCommand(
public async Task PutVNext_UsesVNextSavePolicyCommand(
SutProvider<PoliciesController> sutProvider, Guid orgId,
SavePolicyRequest model, Policy policy, Guid userId)
{
@@ -478,10 +481,6 @@ public class PoliciesControllerTests
.OrganizationOwner(orgId)
.Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)
.Returns(true);
sutProvider.GetDependency<IVNextSavePolicyCommand>()
.SaveAsync(Arg.Any<SavePolicyModel>())
.Returns(policy);
@@ -492,12 +491,11 @@ public class PoliciesControllerTests
// Assert
await sutProvider.GetDependency<IVNextSavePolicyCommand>()
.Received(1)
.SaveAsync(Arg.Is<SavePolicyModel>(
m => m.PolicyUpdate.OrganizationId == orgId &&
m.PolicyUpdate.Type == policy.Type &&
m.PolicyUpdate.Enabled == model.Policy.Enabled &&
m.PerformedBy.UserId == userId &&
m.PerformedBy.IsOrganizationOwnerOrProvider == true));
.SaveAsync(Arg.Is<SavePolicyModel>(m => m.PolicyUpdate.OrganizationId == orgId &&
m.PolicyUpdate.Type == policy.Type &&
m.PolicyUpdate.Enabled == model.Policy.Enabled &&
m.PerformedBy.UserId == userId &&
m.PerformedBy.IsOrganizationOwnerOrProvider == true));
await sutProvider.GetDependency<ISavePolicyCommand>()
.DidNotReceiveWithAnyArgs()
@@ -507,51 +505,4 @@ public class PoliciesControllerTests
Assert.Equal(policy.Id, result.Id);
Assert.Equal(policy.Type, result.Type);
}
[Theory]
[BitAutoData]
public async Task PutVNext_WhenPolicyValidatorsRefactorDisabled_UsesSavePolicyCommand(
SutProvider<PoliciesController> sutProvider, Guid orgId,
SavePolicyRequest model, Policy policy, Guid userId)
{
// Arrange
policy.Data = null;
sutProvider.GetDependency<ICurrentContext>()
.UserId
.Returns(userId);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(orgId)
.Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)
.Returns(false);
sutProvider.GetDependency<ISavePolicyCommand>()
.VNextSaveAsync(Arg.Any<SavePolicyModel>())
.Returns(policy);
// Act
var result = await sutProvider.Sut.PutVNext(orgId, policy.Type, model);
// Assert
await sutProvider.GetDependency<ISavePolicyCommand>()
.Received(1)
.VNextSaveAsync(Arg.Is<SavePolicyModel>(
m => m.PolicyUpdate.OrganizationId == orgId &&
m.PolicyUpdate.Type == policy.Type &&
m.PolicyUpdate.Enabled == model.Policy.Enabled &&
m.PerformedBy.UserId == userId &&
m.PerformedBy.IsOrganizationOwnerOrProvider == true));
await sutProvider.GetDependency<IVNextSavePolicyCommand>()
.DidNotReceiveWithAnyArgs()
.SaveAsync(default);
Assert.NotNull(result);
Assert.Equal(policy.Id, result.Id);
Assert.Equal(policy.Type, result.Type);
}
}

View File

@@ -14,6 +14,7 @@ using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Models.Api.Request;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Repositories;

View File

@@ -1,6 +1,6 @@
#nullable enable
using Bit.Api.KeyManagement.Models.Requests;
using Bit.Core.KeyManagement.Models.Api.Request;
using Xunit;
namespace Bit.Api.Test.KeyManagement.Models.Request;

View File

@@ -0,0 +1,307 @@
using Bit.Api.SecretsManager.Controllers;
using Bit.Api.SecretsManager.Models.Request;
using Bit.Core.Auth.Identity;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.SecretsManager.AutoFixture.SecretsFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.SecretsManager.Controllers;
[ControllerCustomize(typeof(SecretVersionsController))]
[SutProviderCustomize]
[SecretCustomize]
public class SecretVersionsControllerTests
{
[Theory]
[BitAutoData]
public async Task GetVersionsBySecretId_SecretNotFound_Throws(
SutProvider<SecretVersionsController> sutProvider,
Guid secretId)
{
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secretId).Returns((Secret?)null);
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.GetVersionsBySecretIdAsync(secretId));
}
[Theory]
[BitAutoData]
public async Task GetVersionsBySecretId_NoAccess_Throws(
SutProvider<SecretVersionsController> sutProvider,
Secret secret)
{
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(false);
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.GetVersionsBySecretIdAsync(secret.Id));
}
[Theory]
[BitAutoData]
public async Task GetVersionsBySecretId_NoReadAccess_Throws(
SutProvider<SecretVersionsController> sutProvider,
Secret secret,
Guid userId)
{
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(false);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((false, false));
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.GetVersionsBySecretIdAsync(secret.Id));
}
[Theory]
[BitAutoData]
public async Task GetVersionsBySecretId_Success(
SutProvider<SecretVersionsController> sutProvider,
Secret secret,
List<SecretVersion> versions,
Guid userId)
{
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(false);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((true, false));
foreach (var version in versions)
{
version.SecretId = secret.Id;
}
sutProvider.GetDependency<ISecretVersionRepository>().GetManyBySecretIdAsync(secret.Id).Returns(versions);
var result = await sutProvider.Sut.GetVersionsBySecretIdAsync(secret.Id);
Assert.Equal(versions.Count, result.Data.Count());
await sutProvider.GetDependency<ISecretVersionRepository>().Received(1)
.GetManyBySecretIdAsync(Arg.Is(secret.Id));
}
[Theory]
[BitAutoData]
public async Task GetById_VersionNotFound_Throws(
SutProvider<SecretVersionsController> sutProvider,
Guid versionId)
{
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(versionId).Returns((SecretVersion?)null);
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.GetByIdAsync(versionId));
}
[Theory]
[BitAutoData]
public async Task GetById_Success(
SutProvider<SecretVersionsController> sutProvider,
SecretVersion version,
Secret secret,
Guid userId)
{
version.SecretId = secret.Id;
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(version.Id).Returns(version);
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(false);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((true, false));
var result = await sutProvider.Sut.GetByIdAsync(version.Id);
Assert.Equal(version.Id, result.Id);
Assert.Equal(version.SecretId, result.SecretId);
}
[Theory]
[BitAutoData]
public async Task RestoreVersion_NoWriteAccess_Throws(
SutProvider<SecretVersionsController> sutProvider,
Secret secret,
SecretVersion version,
RestoreSecretVersionRequestModel request,
Guid userId)
{
version.SecretId = secret.Id;
request.VersionId = version.Id;
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(false);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((true, false));
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.RestoreVersionAsync(secret.Id, request));
}
[Theory]
[BitAutoData]
public async Task RestoreVersion_VersionNotFound_Throws(
SutProvider<SecretVersionsController> sutProvider,
Secret secret,
RestoreSecretVersionRequestModel request,
Guid userId)
{
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((true, true));
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(request.VersionId).Returns((SecretVersion?)null);
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.RestoreVersionAsync(secret.Id, request));
}
[Theory]
[BitAutoData]
public async Task RestoreVersion_VersionBelongsToDifferentSecret_Throws(
SutProvider<SecretVersionsController> sutProvider,
Secret secret,
SecretVersion version,
RestoreSecretVersionRequestModel request,
Guid userId)
{
version.SecretId = Guid.NewGuid(); // Different secret
request.VersionId = version.Id;
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((true, true));
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(request.VersionId).Returns(version);
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.RestoreVersionAsync(secret.Id, request));
}
[Theory]
[BitAutoData]
public async Task RestoreVersion_Success(
SutProvider<SecretVersionsController> sutProvider,
Secret secret,
SecretVersion version,
RestoreSecretVersionRequestModel request,
Guid userId,
OrganizationUser organizationUser)
{
version.SecretId = secret.Id;
request.VersionId = version.Id;
var versionValue = version.Value;
organizationUser.OrganizationId = secret.OrganizationId;
organizationUser.UserId = userId;
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((true, true));
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(request.VersionId).Returns(version);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(secret.OrganizationId, userId).Returns(organizationUser);
sutProvider.GetDependency<ISecretRepository>().UpdateAsync(Arg.Any<Secret>()).Returns(x => x.Arg<Secret>());
var result = await sutProvider.Sut.RestoreVersionAsync(secret.Id, request);
await sutProvider.GetDependency<ISecretRepository>().Received(1)
.UpdateAsync(Arg.Is<Secret>(s => s.Value == versionValue));
}
[Theory]
[BitAutoData]
public async Task BulkDelete_EmptyIds_Throws(
SutProvider<SecretVersionsController> sutProvider)
{
await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.BulkDeleteAsync(new List<Guid>()));
}
[Theory]
[BitAutoData]
public async Task BulkDelete_VersionNotFound_Throws(
SutProvider<SecretVersionsController> sutProvider,
List<Guid> ids)
{
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(ids[0]).Returns((SecretVersion?)null);
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.BulkDeleteAsync(ids));
}
[Theory]
[BitAutoData]
public async Task BulkDelete_NoWriteAccess_Throws(
SutProvider<SecretVersionsController> sutProvider,
List<SecretVersion> versions,
Secret secret,
Guid userId)
{
var ids = versions.Select(v => v.Id).ToList();
foreach (var version in versions)
{
version.SecretId = secret.Id;
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(version.Id).Returns(version);
}
sutProvider.GetDependency<ISecretRepository>().GetManyByIds(Arg.Any<IEnumerable<Guid>>())
.Returns(new List<Secret> { secret });
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(false);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((true, false));
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.BulkDeleteAsync(ids));
}
[Theory]
[BitAutoData]
public async Task BulkDelete_Success(
SutProvider<SecretVersionsController> sutProvider,
List<SecretVersion> versions,
Secret secret,
Guid userId)
{
var ids = versions.Select(v => v.Id).ToList();
foreach (var version in versions)
{
version.SecretId = secret.Id;
}
sutProvider.GetDependency<ISecretVersionRepository>().GetManyByIdsAsync(ids).Returns(versions);
sutProvider.GetDependency<ISecretRepository>().GetManyByIds(Arg.Any<IEnumerable<Guid>>())
.Returns(new List<Secret> { secret });
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<ICurrentContext>().IdentityClientType.Returns(IdentityClientType.ServiceAccount);
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(true);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
.ReturnsForAnyArgs((true, true));
await sutProvider.Sut.BulkDeleteAsync(ids);
await sutProvider.GetDependency<ISecretVersionRepository>().Received(1)
.DeleteManyByIdAsync(Arg.Is<IEnumerable<Guid>>(x => x.SequenceEqual(ids)));
}
}

View File

@@ -2,6 +2,7 @@
using Bit.Api.SecretsManager.Controllers;
using Bit.Api.SecretsManager.Models.Request;
using Bit.Api.Test.SecretsManager.Enums;
using Bit.Core.Auth.Identity;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@@ -244,6 +245,7 @@ public class SecretsControllerTests
{
data = SetupSecretUpdateRequest(data);
SetControllerUser(sutProvider, new Guid());
sutProvider.GetDependency<ICurrentContext>().IdentityClientType.Returns(IdentityClientType.ServiceAccount);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(), Arg.Any<Secret>(),
Arg.Any<IEnumerable<IAuthorizationRequirement>>()).ReturnsForAnyArgs(AuthorizationResult.Success());
@@ -602,6 +604,7 @@ public class SecretsControllerTests
{
data = SetupSecretUpdateRequest(data, true);
sutProvider.GetDependency<ICurrentContext>().IdentityClientType.Returns(IdentityClientType.ServiceAccount);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(), Arg.Any<Secret>(),
Arg.Any<IEnumerable<IAuthorizationRequirement>>()).Returns(AuthorizationResult.Success());

View File

@@ -79,7 +79,7 @@ public class CiphersControllerTests
sutProvider.GetDependency<ICipherRepository>().GetByIdAsync(id, userId).ReturnsForAnyArgs(cipherDetails);
sutProvider.GetDependency<ICollectionCipherRepository>().GetManyByUserIdCipherIdAsync(userId, id).Returns((ICollection<CollectionCipher>)new List<CollectionCipher>());
sutProvider.GetDependency<IApplicationCacheService>().GetOrganizationAbilitiesAsync().Returns(new Dictionary<Guid, OrganizationAbility> { { cipherDetails.OrganizationId.Value, new OrganizationAbility() } });
sutProvider.GetDependency<IApplicationCacheService>().GetOrganizationAbilitiesAsync().Returns(new Dictionary<Guid, OrganizationAbility> { { cipherDetails.OrganizationId.Value, new OrganizationAbility { Id = cipherDetails.OrganizationId.Value } } });
var cipherService = sutProvider.GetDependency<ICipherService>();
await sutProvider.Sut.PutCollections_vNext(id, model);
@@ -95,7 +95,7 @@ public class CiphersControllerTests
sutProvider.GetDependency<ICipherRepository>().GetByIdAsync(id, userId).ReturnsForAnyArgs(cipherDetails);
sutProvider.GetDependency<ICollectionCipherRepository>().GetManyByUserIdCipherIdAsync(userId, id).Returns((ICollection<CollectionCipher>)new List<CollectionCipher>());
sutProvider.GetDependency<IApplicationCacheService>().GetOrganizationAbilitiesAsync().Returns(new Dictionary<Guid, OrganizationAbility> { { cipherDetails.OrganizationId.Value, new OrganizationAbility() } });
sutProvider.GetDependency<IApplicationCacheService>().GetOrganizationAbilitiesAsync().Returns(new Dictionary<Guid, OrganizationAbility> { { cipherDetails.OrganizationId.Value, new OrganizationAbility { Id = cipherDetails.OrganizationId.Value } } });
var result = await sutProvider.Sut.PutCollections_vNext(id, model);
@@ -1790,118 +1790,6 @@ public class CiphersControllerTests
);
}
[Theory, BitAutoData]
public async Task PutShareMany_ArchivedCipher_ThrowsBadRequestException(
Guid organizationId,
Guid userId,
CipherWithIdRequestModel request,
SutProvider<CiphersController> sutProvider)
{
request.EncryptedFor = userId;
request.OrganizationId = organizationId.ToString();
request.ArchivedDate = DateTime.UtcNow;
var model = new CipherBulkShareRequestModel
{
Ciphers = [request],
CollectionIds = [Guid.NewGuid().ToString()]
};
sutProvider.GetDependency<ICurrentContext>()
.OrganizationUser(organizationId)
.Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>()
.GetProperUserId(default)
.ReturnsForAnyArgs(userId);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.PutShareMany(model)
);
Assert.Equal("Cannot move archived items to an organization.", exception.Message);
}
[Theory, BitAutoData]
public async Task PutShareMany_ExistingCipherArchived_ThrowsBadRequestException(
Guid organizationId,
Guid userId,
CipherWithIdRequestModel request,
SutProvider<CiphersController> sutProvider)
{
// Request model does not have ArchivedDate (only the existing cipher does)
request.EncryptedFor = userId;
request.OrganizationId = organizationId.ToString();
request.ArchivedDate = null;
var model = new CipherBulkShareRequestModel
{
Ciphers = [request],
CollectionIds = [Guid.NewGuid().ToString()]
};
// The existing cipher from the repository IS archived
var existingCipher = new CipherDetails
{
Id = request.Id!.Value,
UserId = userId,
Type = CipherType.Login,
Data = JsonSerializer.Serialize(new CipherLoginData()),
ArchivedDate = DateTime.UtcNow
};
sutProvider.GetDependency<ICurrentContext>()
.OrganizationUser(organizationId)
.Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>()
.GetProperUserId(default)
.ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICipherRepository>()
.GetManyByUserIdAsync(userId, withOrganizations: false)
.Returns(Task.FromResult((ICollection<CipherDetails>)[existingCipher]));
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.PutShareMany(model)
);
Assert.Equal("Cannot move archived items to an organization.", exception.Message);
}
[Theory, BitAutoData]
public async Task PutShare_ArchivedCipher_ThrowsBadRequestException(
Guid cipherId,
Guid organizationId,
User user,
CipherShareRequestModel model,
SutProvider<CiphersController> sutProvider)
{
model.Cipher.OrganizationId = organizationId.ToString();
model.Cipher.EncryptedFor = user.Id;
var cipher = new Cipher
{
Id = cipherId,
UserId = user.Id,
ArchivedDate = DateTime.UtcNow.AddDays(-1),
Type = CipherType.Login,
Data = JsonSerializer.Serialize(new CipherLoginData())
};
sutProvider.GetDependency<IUserService>()
.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(user);
sutProvider.GetDependency<ICipherRepository>()
.GetByIdAsync(cipherId)
.Returns(cipher);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationUser(organizationId)
.Returns(Task.FromResult(true));
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.PutShare(cipherId, model)
);
Assert.Equal("Cannot move an archived item to an organization.", exception.Message);
}
[Theory, BitAutoData]
public async Task PostPurge_WhenUserNotFound_ThrowsUnauthorizedAccessException(
SecretVerificationRequestModel model,

View File

@@ -0,0 +1,388 @@
using Bit.Billing.Constants;
using Bit.Billing.Jobs;
using Bit.Billing.Services;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Repositories;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Quartz;
using Stripe;
using Xunit;
namespace Bit.Billing.Test.Jobs;
public class SubscriptionCancellationJobTests
{
private readonly IStripeFacade _stripeFacade;
private readonly IOrganizationRepository _organizationRepository;
private readonly SubscriptionCancellationJob _sut;
public SubscriptionCancellationJobTests()
{
_stripeFacade = Substitute.For<IStripeFacade>();
_organizationRepository = Substitute.For<IOrganizationRepository>();
_sut = new SubscriptionCancellationJob(_stripeFacade, _organizationRepository, Substitute.For<ILogger<SubscriptionCancellationJob>>());
}
[Fact]
public async Task Execute_OrganizationIsNull_SkipsCancellation()
{
// Arrange
const string subscriptionId = "sub_123";
var organizationId = Guid.NewGuid();
var context = CreateJobExecutionContext(subscriptionId, organizationId);
_organizationRepository.GetByIdAsync(organizationId).Returns((Organization)null);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(Arg.Any<string>(), Arg.Any<SubscriptionGetOptions>());
await _stripeFacade.DidNotReceiveWithAnyArgs().CancelSubscription(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
}
[Fact]
public async Task Execute_OrganizationIsEnabled_SkipsCancellation()
{
// Arrange
const string subscriptionId = "sub_123";
var organizationId = Guid.NewGuid();
var context = CreateJobExecutionContext(subscriptionId, organizationId);
var organization = new Organization
{
Id = organizationId,
Enabled = true
};
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(Arg.Any<string>(), Arg.Any<SubscriptionGetOptions>());
await _stripeFacade.DidNotReceiveWithAnyArgs().CancelSubscription(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
}
[Fact]
public async Task Execute_SubscriptionStatusIsNotUnpaid_SkipsCancellation()
{
// Arrange
const string subscriptionId = "sub_123";
var organizationId = Guid.NewGuid();
var context = CreateJobExecutionContext(subscriptionId, organizationId);
var organization = new Organization
{
Id = organizationId,
Enabled = false
};
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
var subscription = new Subscription
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Active,
LatestInvoice = new Invoice
{
BillingReason = "subscription_cycle"
}
};
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
.Returns(subscription);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.DidNotReceive().CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
}
[Fact]
public async Task Execute_BillingReasonIsInvalid_SkipsCancellation()
{
// Arrange
const string subscriptionId = "sub_123";
var organizationId = Guid.NewGuid();
var context = CreateJobExecutionContext(subscriptionId, organizationId);
var organization = new Organization
{
Id = organizationId,
Enabled = false
};
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
var subscription = new Subscription
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Unpaid,
LatestInvoice = new Invoice
{
BillingReason = "manual"
}
};
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
.Returns(subscription);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.DidNotReceive().CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
}
[Fact]
public async Task Execute_ValidConditions_CancelsSubscriptionAndVoidsInvoices()
{
// Arrange
const string subscriptionId = "sub_123";
var organizationId = Guid.NewGuid();
var context = CreateJobExecutionContext(subscriptionId, organizationId);
var organization = new Organization
{
Id = organizationId,
Enabled = false
};
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
var subscription = new Subscription
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Unpaid,
LatestInvoice = new Invoice
{
BillingReason = "subscription_cycle"
}
};
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
.Returns(subscription);
var invoices = new StripeList<Invoice>
{
Data =
[
new Invoice { Id = "inv_1" },
new Invoice { Id = "inv_2" }
],
HasMore = false
};
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>()).Returns(invoices);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
await _stripeFacade.Received(1).VoidInvoice("inv_1");
await _stripeFacade.Received(1).VoidInvoice("inv_2");
}
[Fact]
public async Task Execute_WithSubscriptionCreateBillingReason_CancelsSubscription()
{
// Arrange
const string subscriptionId = "sub_123";
var organizationId = Guid.NewGuid();
var context = CreateJobExecutionContext(subscriptionId, organizationId);
var organization = new Organization
{
Id = organizationId,
Enabled = false
};
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
var subscription = new Subscription
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Unpaid,
LatestInvoice = new Invoice
{
BillingReason = "subscription_create"
}
};
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
.Returns(subscription);
var invoices = new StripeList<Invoice>
{
Data = [],
HasMore = false
};
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>()).Returns(invoices);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
}
[Fact]
public async Task Execute_NoOpenInvoices_CancelsSubscriptionOnly()
{
// Arrange
const string subscriptionId = "sub_123";
var organizationId = Guid.NewGuid();
var context = CreateJobExecutionContext(subscriptionId, organizationId);
var organization = new Organization
{
Id = organizationId,
Enabled = false
};
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
var subscription = new Subscription
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Unpaid,
LatestInvoice = new Invoice
{
BillingReason = "subscription_cycle"
}
};
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
.Returns(subscription);
var invoices = new StripeList<Invoice>
{
Data = [],
HasMore = false
};
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>()).Returns(invoices);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
await _stripeFacade.DidNotReceiveWithAnyArgs().VoidInvoice(Arg.Any<string>());
}
[Fact]
public async Task Execute_WithPagination_VoidsAllInvoices()
{
// Arrange
const string subscriptionId = "sub_123";
var organizationId = Guid.NewGuid();
var context = CreateJobExecutionContext(subscriptionId, organizationId);
var organization = new Organization
{
Id = organizationId,
Enabled = false
};
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
var subscription = new Subscription
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Unpaid,
LatestInvoice = new Invoice
{
BillingReason = "subscription_cycle"
}
};
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
.Returns(subscription);
// First page of invoices
var firstPage = new StripeList<Invoice>
{
Data =
[
new Invoice { Id = "inv_1" },
new Invoice { Id = "inv_2" }
],
HasMore = true
};
// Second page of invoices
var secondPage = new StripeList<Invoice>
{
Data =
[
new Invoice { Id = "inv_3" },
new Invoice { Id = "inv_4" }
],
HasMore = false
};
_stripeFacade.ListInvoices(Arg.Is<InvoiceListOptions>(o => o.StartingAfter == null))
.Returns(firstPage);
_stripeFacade.ListInvoices(Arg.Is<InvoiceListOptions>(o => o.StartingAfter == "inv_2"))
.Returns(secondPage);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
await _stripeFacade.Received(1).VoidInvoice("inv_1");
await _stripeFacade.Received(1).VoidInvoice("inv_2");
await _stripeFacade.Received(1).VoidInvoice("inv_3");
await _stripeFacade.Received(1).VoidInvoice("inv_4");
await _stripeFacade.Received(2).ListInvoices(Arg.Any<InvoiceListOptions>());
}
[Fact]
public async Task Execute_ListInvoicesCalledWithCorrectOptions()
{
// Arrange
const string subscriptionId = "sub_123";
var organizationId = Guid.NewGuid();
var context = CreateJobExecutionContext(subscriptionId, organizationId);
var organization = new Organization
{
Id = organizationId,
Enabled = false
};
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
var subscription = new Subscription
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Unpaid,
LatestInvoice = new Invoice
{
BillingReason = "subscription_cycle"
}
};
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
.Returns(subscription);
var invoices = new StripeList<Invoice>
{
Data = [],
HasMore = false
};
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>()).Returns(invoices);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")));
await _stripeFacade.Received(1).ListInvoices(Arg.Is<InvoiceListOptions>(o =>
o.Status == "open" &&
o.Subscription == subscriptionId &&
o.Limit == 100));
}
private static IJobExecutionContext CreateJobExecutionContext(string subscriptionId, Guid organizationId)
{
var context = Substitute.For<IJobExecutionContext>();
var jobDataMap = new JobDataMap
{
{ "subscriptionId", subscriptionId },
{ "organizationId", organizationId.ToString() }
};
context.MergedJobDataMap.Returns(jobDataMap);
return context;
}
}

View File

@@ -13,6 +13,7 @@ using Bit.Core.Billing.Pricing.Premium;
using Bit.Core.Entities;
using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal;
using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
using Bit.Core.Models.Mail.Billing.Renewal.Premium;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Repositories;
@@ -253,6 +254,9 @@ public class UpcomingInvoiceHandlerTests
.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2)
.Returns(true);
var coupon = new Coupon { PercentOff = 20, Id = CouponIDs.Milestone2SubscriptionDiscount };
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
// Act
await _sut.HandleAsync(parsedEvent);
@@ -260,6 +264,7 @@ public class UpcomingInvoiceHandlerTests
// Assert
await _userRepository.Received(1).GetByIdAsync(_userId);
await _pricingClient.Received(1).GetAvailablePremiumPlan();
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone2SubscriptionDiscount);
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is("sub_123"),
Arg.Is<SubscriptionUpdateOptions>(o =>
@@ -269,11 +274,15 @@ public class UpcomingInvoiceHandlerTests
o.ProrationBehavior == "none"));
// Verify the updated invoice email was sent with correct price
var discountedPrice = plan.Seat.Price * (100 - coupon.PercentOff.Value) / 100;
await _mailer.Received(1).SendEmail(
Arg.Is<Families2020RenewalMail>(email =>
Arg.Is<PremiumRenewalMail>(email =>
email.ToEmails.Contains("user@example.com") &&
email.Subject == "Your Bitwarden Families renewal is updating" &&
email.View.MonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US"))));
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.DiscountAmount == $"{coupon.PercentOff}%"
));
}
[Fact]
@@ -1474,6 +1483,200 @@ public class UpcomingInvoiceHandlerTests
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2020RenewalMail>());
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndCouponNotFound_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var families2019Plan = new Families2019Plan();
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns((Coupon)null);
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Exception is caught, error is logged, and traditional email is sent
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") &&
o.ToString().Contains(parsedEvent.Type) &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Is<Exception>(e => e is InvalidOperationException && e.Message.Contains("Coupon for sending families 2019 email")),
Arg.Any<Func<object, Exception, string>>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2019RenewalMail>());
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("org@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndCouponPercentOffIsNull_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var families2019Plan = new Families2019Plan();
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
var coupon = new Coupon
{
Id = CouponIDs.Milestone3SubscriptionDiscount,
PercentOff = null
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Exception is caught, error is logged, and traditional email is sent
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") &&
o.ToString().Contains(parsedEvent.Type) &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Is<Exception>(e => e is InvalidOperationException && e.Message.Contains("coupon.PercentOff")),
Arg.Any<Func<object, Exception, string>>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2019RenewalMail>());
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("org@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnExists_DeletesItem()
{
@@ -1996,4 +2199,332 @@ public class UpcomingInvoiceHandlerTests
await _organizationRepository.DidNotReceive().ReplaceAsync(
Arg.Is<Organization>(org => org.PlanType == PlanType.FamiliesAnnually));
}
#region Premium Renewal Email Tests
[Fact]
public async Task HandleAsync_WhenMilestone2Enabled_AndCouponNotFound_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var customerId = "cus_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = customerId },
Metadata = new Dictionary<string, string>()
};
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
var plan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
};
var customer = new Customer
{
Id = customerId,
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported },
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(user);
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns((Coupon)null);
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Exception is caught, error is logged, and traditional email is sent
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Is<Exception>(e => e is InvalidOperationException
&& e.Message == $"Coupon for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} not found"),
Arg.Any<Func<object, Exception, string>>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<PremiumRenewalMail>());
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("user@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
}
[Fact]
public async Task HandleAsync_WhenMilestone2Enabled_AndCouponPercentOffIsNull_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var customerId = "cus_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = customerId },
Metadata = new Dictionary<string, string>()
};
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
var plan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
};
var customer = new Customer
{
Id = customerId,
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported },
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
var coupon = new Coupon
{
Id = CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = null
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(user);
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Exception is caught, error is logged, and traditional email is sent
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Is<Exception>(e => e is InvalidOperationException
&& e.Message == $"coupon.PercentOff for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} is null"),
Arg.Any<Func<object, Exception, string>>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<PremiumRenewalMail>());
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("user@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
}
[Fact]
public async Task HandleAsync_WhenMilestone2Enabled_AndValidCoupon_SendsPremiumRenewalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var customerId = "cus_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = customerId },
Metadata = new Dictionary<string, string>()
};
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
var plan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
};
var customer = new Customer
{
Id = customerId,
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported },
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
var coupon = new Coupon
{
Id = CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = 30
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(user);
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
var expectedDiscountedPrice = plan.Seat.Price * (100 - coupon.PercentOff.Value) / 100;
await _mailer.Received(1).SendEmail(
Arg.Is<PremiumRenewalMail>(email =>
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.DiscountAmount == "30%" &&
email.View.DiscountedMonthlyRenewalPrice == (expectedDiscountedPrice / 12).ToString("C", new CultureInfo("en-US"))
));
await _mailService.DidNotReceive().SendInvoiceUpcoming(
Arg.Any<IEnumerable<string>>(),
Arg.Any<decimal>(),
Arg.Any<DateTime>(),
Arg.Any<List<string>>(),
Arg.Any<bool>());
}
[Fact]
public async Task HandleAsync_WhenMilestone2Enabled_AndGetCouponThrowsException_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var customerId = "cus_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 10000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
{
Id = "sub_123",
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = customerId },
Metadata = new Dictionary<string, string>()
};
var user = new User { Id = _userId, Email = "user@example.com", Premium = true };
var plan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
};
var customer = new Customer
{
Id = customerId,
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported },
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
_userRepository.GetByIdAsync(_userId).Returns(user);
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount)
.ThrowsAsync(new StripeException("Stripe API error"));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - Exception is caught, error is logged, and traditional email is sent
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Is<Exception>(e => e is StripeException),
Arg.Any<Func<object, Exception, string>>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<PremiumRenewalMail>());
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("user@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
}
#endregion
}

View File

@@ -0,0 +1,161 @@
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using NSubstitute;
using StackExchange.Redis;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.AdminConsole.EventIntegrations;
public class EventIntegrationServiceCollectionExtensionsTests
{
private readonly IServiceCollection _services;
private readonly GlobalSettings _globalSettings;
public EventIntegrationServiceCollectionExtensionsTests()
{
_services = new ServiceCollection();
_globalSettings = CreateGlobalSettings([]);
// Add required infrastructure services
_services.TryAddSingleton(_globalSettings);
_services.TryAddSingleton<IGlobalSettings>(_globalSettings);
_services.AddLogging();
// Mock Redis connection for cache
_services.AddSingleton(Substitute.For<IConnectionMultiplexer>());
// Mock required repository dependencies for commands
_services.TryAddScoped(_ => Substitute.For<IOrganizationIntegrationRepository>());
_services.TryAddScoped(_ => Substitute.For<IOrganizationRepository>());
}
[Fact]
public void AddEventIntegrationsCommandsQueries_RegistersAllServices()
{
_services.AddEventIntegrationsCommandsQueries(_globalSettings);
using var provider = _services.BuildServiceProvider();
var cache = provider.GetRequiredKeyedService<IFusionCache>(EventIntegrationsCacheConstants.CacheName);
Assert.NotNull(cache);
using var scope = provider.CreateScope();
var sp = scope.ServiceProvider;
Assert.NotNull(sp.GetService<ICreateOrganizationIntegrationCommand>());
Assert.NotNull(sp.GetService<IUpdateOrganizationIntegrationCommand>());
Assert.NotNull(sp.GetService<IDeleteOrganizationIntegrationCommand>());
Assert.NotNull(sp.GetService<IGetOrganizationIntegrationsQuery>());
}
[Fact]
public void AddEventIntegrationsCommandsQueries_CommandsQueries_AreRegisteredAsScoped()
{
_services.AddEventIntegrationsCommandsQueries(_globalSettings);
var createIntegrationDescriptor = _services.First(s =>
s.ServiceType == typeof(ICreateOrganizationIntegrationCommand));
Assert.Equal(ServiceLifetime.Scoped, createIntegrationDescriptor.Lifetime);
}
[Fact]
public void AddEventIntegrationsCommandsQueries_CommandsQueries_DifferentInstancesPerScope()
{
_services.AddEventIntegrationsCommandsQueries(_globalSettings);
var provider = _services.BuildServiceProvider();
ICreateOrganizationIntegrationCommand? instance1, instance2, instance3;
using (var scope1 = provider.CreateScope())
{
instance1 = scope1.ServiceProvider.GetService<ICreateOrganizationIntegrationCommand>();
}
using (var scope2 = provider.CreateScope())
{
instance2 = scope2.ServiceProvider.GetService<ICreateOrganizationIntegrationCommand>();
}
using (var scope3 = provider.CreateScope())
{
instance3 = scope3.ServiceProvider.GetService<ICreateOrganizationIntegrationCommand>();
}
Assert.NotNull(instance1);
Assert.NotNull(instance2);
Assert.NotNull(instance3);
Assert.NotSame(instance1, instance2);
Assert.NotSame(instance2, instance3);
Assert.NotSame(instance1, instance3);
}
[Fact]
public void AddEventIntegrationsCommandsQueries_CommandsQueries__SameInstanceWithinScope()
{
_services.AddEventIntegrationsCommandsQueries(_globalSettings);
var provider = _services.BuildServiceProvider();
using var scope = provider.CreateScope();
var instance1 = scope.ServiceProvider.GetService<ICreateOrganizationIntegrationCommand>();
var instance2 = scope.ServiceProvider.GetService<ICreateOrganizationIntegrationCommand>();
Assert.NotNull(instance1);
Assert.NotNull(instance2);
Assert.Same(instance1, instance2);
}
[Fact]
public void AddEventIntegrationsCommandsQueries_MultipleCalls_IsIdempotent()
{
_services.AddEventIntegrationsCommandsQueries(_globalSettings);
_services.AddEventIntegrationsCommandsQueries(_globalSettings);
_services.AddEventIntegrationsCommandsQueries(_globalSettings);
var createConfigCmdDescriptors = _services.Where(s =>
s.ServiceType == typeof(ICreateOrganizationIntegrationCommand)).ToList();
Assert.Single(createConfigCmdDescriptors);
var updateIntegrationCmdDescriptors = _services.Where(s =>
s.ServiceType == typeof(IUpdateOrganizationIntegrationCommand)).ToList();
Assert.Single(updateIntegrationCmdDescriptors);
}
[Fact]
public void AddOrganizationIntegrationCommandsQueries_RegistersAllIntegrationServices()
{
_services.AddOrganizationIntegrationCommandsQueries();
Assert.Contains(_services, s => s.ServiceType == typeof(ICreateOrganizationIntegrationCommand));
Assert.Contains(_services, s => s.ServiceType == typeof(IUpdateOrganizationIntegrationCommand));
Assert.Contains(_services, s => s.ServiceType == typeof(IDeleteOrganizationIntegrationCommand));
Assert.Contains(_services, s => s.ServiceType == typeof(IGetOrganizationIntegrationsQuery));
}
[Fact]
public void AddOrganizationIntegrationCommandsQueries_MultipleCalls_IsIdempotent()
{
_services.AddOrganizationIntegrationCommandsQueries();
_services.AddOrganizationIntegrationCommandsQueries();
_services.AddOrganizationIntegrationCommandsQueries();
var createCmdDescriptors = _services.Where(s =>
s.ServiceType == typeof(ICreateOrganizationIntegrationCommand)).ToList();
Assert.Single(createCmdDescriptors);
}
private static GlobalSettings CreateGlobalSettings(Dictionary<string, string?> data)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(data)
.Build();
var settings = new GlobalSettings();
config.GetSection("GlobalSettings").Bind(settings);
return settings;
}
}

View File

@@ -0,0 +1,92 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrations;
[SutProviderCustomize]
public class CreateOrganizationIntegrationCommandTests
{
[Theory, BitAutoData]
public async Task CreateAsync_Success_CreatesIntegrationAndInvalidatesCache(
SutProvider<CreateOrganizationIntegrationCommand> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Webhook;
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(integration.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.CreateAsync(integration)
.Returns(integration);
var result = await sutProvider.Sut.CreateAsync(integration);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetManyByOrganizationAsync(integration.OrganizationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.CreateAsync(integration);
await sutProvider.GetDependency<IFusionCache>().Received(1)
.RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
integration.OrganizationId,
integration.Type));
Assert.Equal(integration, result);
}
[Theory, BitAutoData]
public async Task CreateAsync_DuplicateType_ThrowsBadRequest(
SutProvider<CreateOrganizationIntegrationCommand> sutProvider,
OrganizationIntegration integration,
OrganizationIntegration existingIntegration)
{
integration.Type = IntegrationType.Webhook;
existingIntegration.Type = IntegrationType.Webhook;
existingIntegration.OrganizationId = integration.OrganizationId;
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(integration.OrganizationId)
.Returns([existingIntegration]);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateAsync(integration));
Assert.Contains("An integration of this type already exists", exception.Message);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.CreateAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task CreateAsync_DifferentType_Success(
SutProvider<CreateOrganizationIntegrationCommand> sutProvider,
OrganizationIntegration integration,
OrganizationIntegration existingIntegration)
{
integration.Type = IntegrationType.Webhook;
existingIntegration.Type = IntegrationType.Slack;
existingIntegration.OrganizationId = integration.OrganizationId;
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(integration.OrganizationId)
.Returns([existingIntegration]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.CreateAsync(integration)
.Returns(integration);
var result = await sutProvider.Sut.CreateAsync(integration);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.CreateAsync(integration);
Assert.Equal(integration, result);
}
}

View File

@@ -0,0 +1,86 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrations;
[SutProviderCustomize]
public class DeleteOrganizationIntegrationCommandTests
{
[Theory, BitAutoData]
public async Task DeleteAsync_Success_DeletesIntegrationAndInvalidatesCache(
SutProvider<DeleteOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId,
OrganizationIntegration integration)
{
integration.Id = integrationId;
integration.OrganizationId = organizationId;
integration.Type = IntegrationType.Webhook;
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns(integration);
await sutProvider.Sut.DeleteAsync(organizationId, integrationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(integrationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.DeleteAsync(integration);
await sutProvider.GetDependency<IFusionCache>().Received(1)
.RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId,
integration.Type));
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<DeleteOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId)
{
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns((OrganizationIntegration)null);
await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.DeleteAsync(organizationId, integrationId));
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.DeleteAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<DeleteOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId,
OrganizationIntegration integration)
{
integration.Id = integrationId;
integration.OrganizationId = Guid.NewGuid(); // Different organization
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns(integration);
await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.DeleteAsync(organizationId, integrationId));
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.DeleteAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
}

View File

@@ -0,0 +1,44 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Bit.Core.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrations;
[SutProviderCustomize]
public class GetOrganizationIntegrationsQueryTests
{
[Theory, BitAutoData]
public async Task GetManyByOrganizationAsync_CallsRepository(
SutProvider<GetOrganizationIntegrationsQuery> sutProvider,
Guid organizationId,
List<OrganizationIntegration> integrations)
{
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(organizationId)
.Returns(integrations);
var result = await sutProvider.Sut.GetManyByOrganizationAsync(organizationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetManyByOrganizationAsync(organizationId);
Assert.Equal(integrations.Count, result.Count);
}
[Theory, BitAutoData]
public async Task GetManyByOrganizationAsync_NoIntegrations_ReturnsEmptyList(
SutProvider<GetOrganizationIntegrationsQuery> sutProvider,
Guid organizationId)
{
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(organizationId)
.Returns([]);
var result = await sutProvider.Sut.GetManyByOrganizationAsync(organizationId);
Assert.Empty(result);
}
}

View File

@@ -0,0 +1,121 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrations;
[SutProviderCustomize]
public class UpdateOrganizationIntegrationCommandTests
{
[Theory, BitAutoData]
public async Task UpdateAsync_Success_UpdatesIntegrationAndInvalidatesCache(
SutProvider<UpdateOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId,
OrganizationIntegration existingIntegration,
OrganizationIntegration updatedIntegration)
{
existingIntegration.Id = integrationId;
existingIntegration.OrganizationId = organizationId;
existingIntegration.Type = IntegrationType.Webhook;
updatedIntegration.Id = integrationId;
updatedIntegration.OrganizationId = organizationId;
updatedIntegration.Type = IntegrationType.Webhook;
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns(existingIntegration);
var result = await sutProvider.Sut.UpdateAsync(organizationId, integrationId, updatedIntegration);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(integrationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.ReplaceAsync(updatedIntegration);
await sutProvider.GetDependency<IFusionCache>().Received(1)
.RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId,
existingIntegration.Type));
Assert.Equal(updatedIntegration, result);
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<UpdateOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId,
OrganizationIntegration updatedIntegration)
{
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns((OrganizationIntegration)null);
await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.UpdateAsync(organizationId, integrationId, updatedIntegration));
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.ReplaceAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<UpdateOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId,
OrganizationIntegration existingIntegration,
OrganizationIntegration updatedIntegration)
{
existingIntegration.Id = integrationId;
existingIntegration.OrganizationId = Guid.NewGuid(); // Different organization
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns(existingIntegration);
await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.UpdateAsync(organizationId, integrationId, updatedIntegration));
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.ReplaceAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationIsDifferentType_ThrowsNotFound(
SutProvider<UpdateOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId,
OrganizationIntegration existingIntegration,
OrganizationIntegration updatedIntegration)
{
existingIntegration.Id = integrationId;
existingIntegration.OrganizationId = organizationId;
existingIntegration.Type = IntegrationType.Webhook;
updatedIntegration.Id = integrationId;
updatedIntegration.OrganizationId = organizationId;
updatedIntegration.Type = IntegrationType.Hec; // Different Type
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns(existingIntegration);
await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.UpdateAsync(organizationId, integrationId, updatedIntegration));
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.ReplaceAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
}

View File

@@ -2,7 +2,6 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.Context;
@@ -183,17 +182,17 @@ public class VerifyOrganizationDomainCommandTests
_ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain);
await sutProvider.GetDependency<ISavePolicyCommand>()
await sutProvider.GetDependency<IVNextSavePolicyCommand>()
.Received(1)
.SaveAsync(Arg.Is<PolicyUpdate>(x => x.Type == PolicyType.SingleOrg &&
x.OrganizationId == domain.OrganizationId &&
x.Enabled &&
.SaveAsync(Arg.Is<SavePolicyModel>(x => x.PolicyUpdate.Type == PolicyType.SingleOrg &&
x.PolicyUpdate.OrganizationId == domain.OrganizationId &&
x.PolicyUpdate.Enabled &&
x.PerformedBy is StandardUser &&
x.PerformedBy.UserId == userId));
}
[Theory, BitAutoData]
public async Task UserVerifyOrganizationDomainAsync_WhenPolicyValidatorsRefactorFlagEnabled_UsesVNextSavePolicyCommand(
public async Task UserVerifyOrganizationDomainAsync_UsesVNextSavePolicyCommand(
OrganizationDomain domain, Guid userId, SutProvider<VerifyOrganizationDomainCommand> sutProvider)
{
sutProvider.GetDependency<IOrganizationDomainRepository>()
@@ -207,10 +206,6 @@ public class VerifyOrganizationDomainCommandTests
sutProvider.GetDependency<ICurrentContext>()
.UserId.Returns(userId);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)
.Returns(true);
_ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain);
await sutProvider.GetDependency<IVNextSavePolicyCommand>()
@@ -240,9 +235,9 @@ public class VerifyOrganizationDomainCommandTests
_ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain);
await sutProvider.GetDependency<ISavePolicyCommand>()
await sutProvider.GetDependency<IVNextSavePolicyCommand>()
.DidNotReceive()
.SaveAsync(Arg.Any<PolicyUpdate>());
.SaveAsync(Arg.Any<SavePolicyModel>());
}
[Theory, BitAutoData]

View File

@@ -0,0 +1,113 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
[SutProviderCustomize]
public class BulkResendOrganizationInvitesCommandTests
{
[Theory]
[BitAutoData]
public async Task BulkResendInvitesAsync_ValidatesUsersAndSendsBatchInvite(
Organization organization,
OrganizationUser validUser1,
OrganizationUser validUser2,
OrganizationUser acceptedUser,
OrganizationUser wrongOrgUser,
SutProvider<BulkResendOrganizationInvitesCommand> sutProvider)
{
validUser1.OrganizationId = organization.Id;
validUser1.Status = OrganizationUserStatusType.Invited;
validUser2.OrganizationId = organization.Id;
validUser2.Status = OrganizationUserStatusType.Invited;
acceptedUser.OrganizationId = organization.Id;
acceptedUser.Status = OrganizationUserStatusType.Accepted;
wrongOrgUser.OrganizationId = Guid.NewGuid();
wrongOrgUser.Status = OrganizationUserStatusType.Invited;
var users = new List<OrganizationUser> { validUser1, validUser2, acceptedUser, wrongOrgUser };
var userIds = users.Select(u => u.Id).ToList();
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(userIds).Returns(users);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var result = (await sutProvider.Sut.BulkResendInvitesAsync(organization.Id, null, userIds)).ToList();
Assert.Equal(4, result.Count);
Assert.Equal(2, result.Count(r => string.IsNullOrEmpty(r.Item2)));
Assert.Equal(2, result.Count(r => r.Item2 == "User invalid."));
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>()
.Received(1)
.SendInvitesAsync(Arg.Is<SendInvitesRequest>(req =>
req.Organization == organization &&
req.Users.Length == 2 &&
req.InitOrganization == false));
}
[Theory]
[BitAutoData]
public async Task BulkResendInvitesAsync_AllInvalidUsers_DoesNotSendInvites(
Organization organization,
List<OrganizationUser> organizationUsers,
SutProvider<BulkResendOrganizationInvitesCommand> sutProvider)
{
foreach (var user in organizationUsers)
{
user.OrganizationId = organization.Id;
user.Status = OrganizationUserStatusType.Confirmed;
}
var userIds = organizationUsers.Select(u => u.Id).ToList();
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(userIds).Returns(organizationUsers);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var result = (await sutProvider.Sut.BulkResendInvitesAsync(organization.Id, null, userIds)).ToList();
Assert.Equal(organizationUsers.Count, result.Count);
Assert.All(result, r => Assert.Equal("User invalid.", r.Item2));
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().DidNotReceive()
.SendInvitesAsync(Arg.Any<SendInvitesRequest>());
}
[Theory]
[BitAutoData]
public async Task BulkResendInvitesAsync_OrganizationNotFound_ThrowsNotFoundException(
Guid organizationId,
List<Guid> userIds,
List<OrganizationUser> organizationUsers,
SutProvider<BulkResendOrganizationInvitesCommand> sutProvider)
{
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(userIds).Returns(organizationUsers);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns((Organization?)null);
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.BulkResendInvitesAsync(organizationId, null, userIds));
}
[Theory]
[BitAutoData]
public async Task BulkResendInvitesAsync_EmptyUserList_ReturnsEmpty(
Organization organization,
SutProvider<BulkResendOrganizationInvitesCommand> sutProvider)
{
var emptyUserIds = new List<Guid>();
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(emptyUserIds).Returns(new List<OrganizationUser>());
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var result = await sutProvider.Sut.BulkResendInvitesAsync(organization.Id, null, emptyUserIds);
Assert.Empty(result);
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().DidNotReceive()
.SendInvitesAsync(Arg.Any<SendInvitesRequest>());
}
}

View File

@@ -1,6 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;

View File

@@ -0,0 +1,215 @@
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.AutoFixture.OrganizationUserFixtures;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
[SutProviderCustomize]
public class RevokeOrganizationUserCommandTests
{
[Theory]
[BitAutoData]
public async Task RevokeUsersAsync_WithValidUsers_RevokesUsersAndLogsEvents(
SutProvider<RevokeOrganizationUserCommand> sutProvider,
Guid organizationId,
Guid actingUserId,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser2)
{
// Arrange
orgUser1.OrganizationId = orgUser2.OrganizationId = organizationId;
orgUser1.UserId = Guid.NewGuid();
orgUser2.UserId = Guid.NewGuid();
var actingUser = CreateActingUser(actingUserId, false, null);
var request = new RevokeOrganizationUsersRequest(
organizationId,
[orgUser1.Id, orgUser2.Id],
actingUser);
SetupRepositoryMocks(sutProvider, [orgUser1, orgUser2]);
SetupValidatorMock(sutProvider, [
ValidationResultHelpers.Valid(orgUser1),
ValidationResultHelpers.Valid(orgUser2)
]);
// Act
var results = (await sutProvider.Sut.RevokeUsersAsync(request)).ToList();
// Assert
Assert.Equal(2, results.Count);
Assert.All(results, r => Assert.True(r.Result.IsSuccess));
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.RevokeManyByIdAsync(Arg.Is<IEnumerable<Guid>>(ids =>
ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id)));
await sutProvider.GetDependency<IEventService>()
.Received(1)
.LogOrganizationUserEventsAsync(Arg.Is<IEnumerable<(OrganizationUser, EventType, DateTime?)>>(
events => events.Count() == 2));
await sutProvider.GetDependency<IPushNotificationService>()
.Received(1)
.PushSyncOrgKeysAsync(orgUser1.UserId!.Value);
await sutProvider.GetDependency<IPushNotificationService>()
.Received(1)
.PushSyncOrgKeysAsync(orgUser2.UserId!.Value);
}
[Theory]
[BitAutoData]
public async Task RevokeUsersAsync_WithSystemUser_LogsEventsWithSystemUserType(
SutProvider<RevokeOrganizationUserCommand> sutProvider,
Guid organizationId,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser)
{
// Arrange
orgUser.OrganizationId = organizationId;
orgUser.UserId = Guid.NewGuid();
var actingUser = CreateActingUser(null, false, EventSystemUser.SCIM);
var request = new RevokeOrganizationUsersRequest(
organizationId,
[orgUser.Id],
actingUser);
SetupRepositoryMocks(sutProvider, [orgUser]);
SetupValidatorMock(sutProvider, [ValidationResultHelpers.Valid(orgUser)]);
// Act
await sutProvider.Sut.RevokeUsersAsync(request);
// Assert
await sutProvider.GetDependency<IEventService>()
.Received(1)
.LogOrganizationUserEventsAsync(Arg.Is<IEnumerable<(OrganizationUser, EventType, EventSystemUser, DateTime?)>>(
events => events.All(e => e.Item3 == EventSystemUser.SCIM)));
}
[Theory]
[BitAutoData]
public async Task RevokeUsersAsync_WithValidationErrors_ReturnsErrorResults(
SutProvider<RevokeOrganizationUserCommand> sutProvider,
Guid organizationId,
Guid actingUserId,
[OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.User)] OrganizationUser orgUser1,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser2)
{
// Arrange
orgUser1.OrganizationId = orgUser2.OrganizationId = organizationId;
var actingUser = CreateActingUser(actingUserId, false, null);
var request = new RevokeOrganizationUsersRequest(
organizationId,
[orgUser1.Id, orgUser2.Id],
actingUser);
SetupRepositoryMocks(sutProvider, [orgUser1, orgUser2]);
SetupValidatorMock(sutProvider, [
ValidationResultHelpers.Invalid(orgUser1, new UserAlreadyRevoked()),
ValidationResultHelpers.Valid(orgUser2)
]);
// Act
var results = (await sutProvider.Sut.RevokeUsersAsync(request)).ToList();
// Assert
Assert.Equal(2, results.Count);
var result1 = results.Single(r => r.Id == orgUser1.Id);
var result2 = results.Single(r => r.Id == orgUser2.Id);
Assert.True(result1.Result.IsError);
Assert.True(result2.Result.IsSuccess);
// Only the valid user should be revoked
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.RevokeManyByIdAsync(Arg.Is<IEnumerable<Guid>>(ids =>
ids.Count() == 1 && ids.Contains(orgUser2.Id)));
}
[Theory]
[BitAutoData]
public async Task RevokeUsersAsync_WhenPushNotificationFails_ContinuesProcessing(
SutProvider<RevokeOrganizationUserCommand> sutProvider,
Guid organizationId,
Guid actingUserId,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser)
{
// Arrange
orgUser.OrganizationId = organizationId;
orgUser.UserId = Guid.NewGuid();
var actingUser = CreateActingUser(actingUserId, false, null);
var request = new RevokeOrganizationUsersRequest(
organizationId,
[orgUser.Id],
actingUser);
SetupRepositoryMocks(sutProvider, [orgUser]);
SetupValidatorMock(sutProvider, [ValidationResultHelpers.Valid(orgUser)]);
sutProvider.GetDependency<IPushNotificationService>()
.PushSyncOrgKeysAsync(orgUser.UserId!.Value)
.Returns(Task.FromException(new Exception("Push notification failed")));
// Act
var results = (await sutProvider.Sut.RevokeUsersAsync(request)).ToList();
// Assert
Assert.Single(results);
Assert.True(results[0].Result.IsSuccess);
// Should log warning but continue
sutProvider.GetDependency<ILogger<RevokeOrganizationUserCommand>>()
.Received()
.Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception?, string>>());
}
private static IActingUser CreateActingUser(Guid? userId, bool isOwnerOrProvider, EventSystemUser? systemUserType) =>
(userId, systemUserType) switch
{
({ } id, _) => new StandardUser(id, isOwnerOrProvider),
(null, { } type) => new SystemUser(type)
};
private static void SetupRepositoryMocks(
SutProvider<RevokeOrganizationUserCommand> sutProvider,
ICollection<OrganizationUser> organizationUsers)
{
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(Arg.Any<IEnumerable<Guid>>())
.Returns(organizationUsers);
}
private static void SetupValidatorMock(
SutProvider<RevokeOrganizationUserCommand> sutProvider,
ICollection<ValidationResult<OrganizationUser>> validationResults)
{
sutProvider.GetDependency<IRevokeOrganizationUserValidator>()
.ValidateAsync(Arg.Any<RevokeOrganizationUsersValidationRequest>())
.Returns(validationResults);
}
}

View File

@@ -0,0 +1,325 @@
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Test.AutoFixture.OrganizationUserFixtures;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
[SutProviderCustomize]
public class RevokeOrganizationUsersValidatorTests
{
[Theory]
[BitAutoData]
public async Task ValidateAsync_WithValidUsers_ReturnsSuccess(
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
Guid organizationId,
Guid actingUserId,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser2)
{
// Arrange
orgUser1.OrganizationId = orgUser2.OrganizationId = organizationId;
orgUser1.UserId = Guid.NewGuid();
orgUser2.UserId = Guid.NewGuid();
var actingUser = CreateActingUser(actingUserId, false, null);
var request = CreateValidationRequest(
organizationId,
[orgUser1, orgUser2],
actingUser);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(true);
// Act
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
// Assert
Assert.Equal(2, results.Count);
Assert.All(results, r => Assert.True(r.IsValid));
}
[Theory]
[BitAutoData]
public async Task ValidateAsync_WithRevokedUser_ReturnsErrorForThatUser(
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
Guid organizationId,
Guid actingUserId,
[OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.User)] OrganizationUser revokedUser)
{
// Arrange
revokedUser.OrganizationId = organizationId;
revokedUser.UserId = Guid.NewGuid();
var actingUser = CreateActingUser(actingUserId, false, null);
var request = CreateValidationRequest(
organizationId,
[revokedUser],
actingUser);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(true);
// Act
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
// Assert
Assert.Single(results);
Assert.True(results.First().IsError);
Assert.IsType<UserAlreadyRevoked>(results.First().AsError);
}
[Theory]
[BitAutoData]
public async Task ValidateAsync_WhenRevokingSelf_ReturnsErrorForThatUser(
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
Guid organizationId,
Guid actingUserId,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser)
{
// Arrange
orgUser.OrganizationId = organizationId;
orgUser.UserId = actingUserId;
var actingUser = CreateActingUser(actingUserId, false, null);
var request = CreateValidationRequest(
organizationId,
[orgUser],
actingUser);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(true);
// Act
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
// Assert
Assert.Single(results);
Assert.True(results.First().IsError);
Assert.IsType<CannotRevokeYourself>(results.First().AsError);
}
[Theory]
[BitAutoData]
public async Task ValidateAsync_WhenNonOwnerRevokesOwner_ReturnsErrorForThatUser(
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
Guid organizationId,
Guid actingUserId,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser ownerUser)
{
// Arrange
ownerUser.OrganizationId = organizationId;
ownerUser.UserId = Guid.NewGuid();
var actingUser = CreateActingUser(actingUserId, false, null);
var request = CreateValidationRequest(
organizationId,
[ownerUser],
actingUser);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(true);
// Act
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
// Assert
Assert.Single(results);
Assert.True(results.First().IsError);
Assert.IsType<OnlyOwnersCanRevokeOwners>(results.First().AsError);
}
[Theory]
[BitAutoData]
public async Task ValidateAsync_WhenOwnerRevokesOwner_ReturnsSuccess(
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
Guid organizationId,
Guid actingUserId,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser ownerUser)
{
// Arrange
ownerUser.OrganizationId = organizationId;
ownerUser.UserId = Guid.NewGuid();
var actingUser = CreateActingUser(actingUserId, true, null);
var request = CreateValidationRequest(
organizationId,
[ownerUser],
actingUser);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(true);
// Act
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
// Assert
Assert.Single(results);
Assert.True(results.First().IsValid);
}
[Theory]
[BitAutoData]
public async Task ValidateAsync_WithMultipleUsers_SomeValid_ReturnsMixedResults(
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
Guid organizationId,
Guid actingUserId,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser validUser,
[OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.User)] OrganizationUser revokedUser)
{
// Arrange
validUser.OrganizationId = revokedUser.OrganizationId = organizationId;
validUser.UserId = Guid.NewGuid();
revokedUser.UserId = Guid.NewGuid();
var actingUser = CreateActingUser(actingUserId, false, null);
var request = CreateValidationRequest(
organizationId,
[validUser, revokedUser],
actingUser);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(true);
// Act
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
// Assert
Assert.Equal(2, results.Count);
var validResult = results.Single(r => r.Request.Id == validUser.Id);
var errorResult = results.Single(r => r.Request.Id == revokedUser.Id);
Assert.True(validResult.IsValid);
Assert.True(errorResult.IsError);
Assert.IsType<UserAlreadyRevoked>(errorResult.AsError);
}
[Theory]
[BitAutoData]
public async Task ValidateAsync_WithSystemUser_DoesNotRequireActingUserId(
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
Guid organizationId,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser)
{
// Arrange
orgUser.OrganizationId = organizationId;
orgUser.UserId = Guid.NewGuid();
var actingUser = CreateActingUser(null, false, EventSystemUser.SCIM);
var request = CreateValidationRequest(
organizationId,
[orgUser],
actingUser);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(true);
// Act
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
// Assert
Assert.Single(results);
Assert.True(results.First().IsValid);
}
[Theory]
[BitAutoData]
public async Task ValidateAsync_WhenRevokingLastOwner_ReturnsErrorForThatUser(
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
Guid organizationId,
Guid actingUserId,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser lastOwner)
{
// Arrange
lastOwner.OrganizationId = organizationId;
lastOwner.UserId = Guid.NewGuid();
var actingUser = CreateActingUser(actingUserId, true, null); // Is an owner
var request = CreateValidationRequest(
organizationId,
[lastOwner],
actingUser);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(false);
// Act
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
// Assert
Assert.Single(results);
Assert.True(results.First().IsError);
Assert.IsType<MustHaveConfirmedOwner>(results.First().AsError);
}
[Theory]
[BitAutoData]
public async Task ValidateAsync_WithMultipleValidationErrors_ReturnsAllErrors(
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
Guid organizationId,
Guid actingUserId,
[OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.User)] OrganizationUser revokedUser,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser ownerUser)
{
// Arrange
revokedUser.OrganizationId = ownerUser.OrganizationId = organizationId;
revokedUser.UserId = Guid.NewGuid();
ownerUser.UserId = Guid.NewGuid();
var actingUser = CreateActingUser(actingUserId, false, null); // Not an owner
var request = CreateValidationRequest(
organizationId,
[revokedUser, ownerUser],
actingUser);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(true);
// Act
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
// Assert
Assert.Equal(2, results.Count);
Assert.All(results, r => Assert.True(r.IsError));
Assert.Contains(results, r => r.AsError is UserAlreadyRevoked);
Assert.Contains(results, r => r.AsError is OnlyOwnersCanRevokeOwners);
}
private static IActingUser CreateActingUser(Guid? userId, bool isOwnerOrProvider, EventSystemUser? systemUserType) =>
(userId, systemUserType) switch
{
({ } id, _) => new StandardUser(id, isOwnerOrProvider),
(null, { } type) => new SystemUser(type)
};
private static RevokeOrganizationUsersValidationRequest CreateValidationRequest(
Guid organizationId,
ICollection<OrganizationUser> organizationUsers,
IActingUser actingUser)
{
return new RevokeOrganizationUsersValidationRequest(
organizationId,
organizationUsers.Select(u => u.Id).ToList(),
actingUser,
organizationUsers
);
}
}

View File

@@ -21,52 +21,23 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidat
public class AutomaticUserConfirmationPolicyEventHandlerTests
{
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_SingleOrgNotEnabled_ReturnsError(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
public void RequiredPolicies_IncludesSingleOrg(
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns((Policy?)null);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
var requiredPolicies = sutProvider.Sut.RequiredPolicies;
// Assert
Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_SingleOrgPolicyDisabled_ReturnsError(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg, false)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase);
Assert.Contains(PolicyType.SingleOrg, requiredPolicies);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_UsersNotCompliantWithSingleOrg_ReturnsError(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid nonCompliantUserId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var orgUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
@@ -85,10 +56,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
Status = OrganizationUserStatusType.Confirmed
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUser]);
@@ -107,13 +74,10 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_UserWithInvitedStatusInOtherOrg_ValidationPasses(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid userId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var orgUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
@@ -121,7 +85,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = userId,
Email = "test@email.com"
};
var otherOrgUser = new OrganizationUser
@@ -133,10 +96,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
Email = orgUser.Email
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUser]);
@@ -146,7 +105,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
.Returns([otherOrgUser]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([]);
// Act
@@ -159,30 +118,37 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_ProviderUsersExist_ReturnsError(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid userId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var orgUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = userId
};
var providerUser = new ProviderUser
{
Id = Guid.NewGuid(),
ProviderId = Guid.NewGuid(),
UserId = Guid.NewGuid(),
UserId = userId,
Status = ProviderUserStatusType.Confirmed
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([providerUser]);
// Act
@@ -196,26 +162,18 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_AllValidationsPassed_ReturnsEmptyString(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var orgUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = Guid.NewGuid(),
Email = "user@example.com"
UserId = Guid.NewGuid()
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUser]);
@@ -225,7 +183,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([]);
// Act
@@ -249,9 +207,10 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
// Assert
Assert.True(string.IsNullOrEmpty(result));
await sutProvider.GetDependency<IPolicyRepository>()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.DidNotReceive()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>());
.GetManyDetailsByOrganizationAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
@@ -268,21 +227,18 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
// Assert
Assert.True(string.IsNullOrEmpty(result));
await sutProvider.GetDependency<IPolicyRepository>()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.DidNotReceive()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>());
.GetManyDetailsByOrganizationAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_IncludesOwnersAndAdmins_InComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid nonCompliantOwnerId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var ownerUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
@@ -290,7 +246,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
Type = OrganizationUserType.Owner,
Status = OrganizationUserStatusType.Confirmed,
UserId = nonCompliantOwnerId,
Email = "owner@example.com"
};
var otherOrgUser = new OrganizationUser
@@ -301,10 +256,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
Status = OrganizationUserStatusType.Confirmed
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([ownerUser]);
@@ -323,12 +274,9 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_InvitedUsersExcluded_FromComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var invitedUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
@@ -339,16 +287,12 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
Email = "invited@example.com"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([invitedUser]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([]);
// Act
@@ -359,14 +303,11 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_RevokedUsersExcluded_FromComplianceCheck(
public async Task ValidateAsync_EnablingPolicy_RevokedUsersIncluded_InComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var revokedUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
@@ -374,38 +315,44 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Revoked,
UserId = Guid.NewGuid(),
Email = "revoked@example.com"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
var additionalOrgUser = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = Guid.NewGuid(),
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Revoked,
UserId = revokedUser.UserId,
};
sutProvider.GetDependency<IOrganizationUserRepository>()
var orgUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
orgUserRepository
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([revokedUser]);
orgUserRepository.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([additionalOrgUser]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_AcceptedUsersIncluded_InComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid nonCompliantUserId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var acceptedUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
@@ -413,7 +360,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Accepted,
UserId = nonCompliantUserId,
Email = "accepted@example.com"
};
var otherOrgUser = new OrganizationUser
@@ -424,10 +370,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
Status = OrganizationUserStatusType.Confirmed
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([acceptedUser]);
@@ -443,186 +385,22 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_EmptyOrganization_ReturnsEmptyString(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_WithSavePolicyModel_CallsValidateWithPolicyUpdate(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var savePolicyModel = new SavePolicyModel(policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_EnablingPolicy_SetsUseAutomaticUserConfirmationToTrue(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
organization.UseAutomaticUserConfirmation = false;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.UseAutomaticUserConfirmation == true &&
o.RevisionDate > DateTime.MinValue));
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_DisablingPolicy_SetsUseAutomaticUserConfirmationToFalse(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
organization.UseAutomaticUserConfirmation = true;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.UseAutomaticUserConfirmation == false &&
o.RevisionDate > DateTime.MinValue));
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_OrganizationNotFound_DoesNotThrowException(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns((Organization?)null);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.DidNotReceive()
.UpsertAsync(Arg.Any<Organization>());
}
[Theory, BitAutoData]
public async Task ExecutePreUpsertSideEffectAsync_CallsOnSaveSideEffectsAsync(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
var savePolicyModel = new SavePolicyModel(policyUpdate);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, currentPolicy);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.UseAutomaticUserConfirmation == policyUpdate.Enabled));
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_UpdatesRevisionDate(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
var originalRevisionDate = DateTime.UtcNow.AddDays(-1);
organization.RevisionDate = originalRevisionDate;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.RevisionDate > originalRevisionDate));
}
}

View File

@@ -1,4 +1,6 @@
using System.Text.Json;
#nullable enable
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.AdminConsole.Repositories;
@@ -8,6 +10,7 @@ using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
@@ -36,12 +39,16 @@ public class EventIntegrationHandlerTests
private SutProvider<EventIntegrationHandler<WebhookIntegrationConfigurationDetails>> GetSutProvider(
List<OrganizationIntegrationConfigurationDetails> configurations)
{
var configurationCache = Substitute.For<IIntegrationConfigurationDetailsCache>();
configurationCache.GetConfigurationDetails(Arg.Any<Guid>(),
IntegrationType.Webhook, Arg.Any<EventType>()).Returns(configurations);
var cache = Substitute.For<IFusionCache>();
cache.GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<object, CancellationToken, Task<List<OrganizationIntegrationConfigurationDetails>>>>(),
options: Arg.Any<FusionCacheEntryOptions>(),
tags: Arg.Any<IEnumerable<string>>()
).Returns(configurations);
return new SutProvider<EventIntegrationHandler<WebhookIntegrationConfigurationDetails>>()
.SetDependency(configurationCache)
.SetDependency(cache)
.SetDependency(_eventIntegrationPublisher)
.SetDependency(IntegrationType.Webhook)
.SetDependency(_logger)
@@ -173,6 +180,37 @@ public class EventIntegrationHandlerTests
Assert.Null(context.ActingUser);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_ActingUserFactory_CallsOrganizationUserRepository(EventMessage eventMessage, OrganizationUserUserDetails actingUser)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithActingUser));
var cache = sutProvider.GetDependency<IFusionCache>();
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
eventMessage.OrganizationId ??= Guid.NewGuid();
eventMessage.ActingUserId ??= Guid.NewGuid();
organizationUserRepository.GetDetailsByOrganizationIdUserIdAsync(
eventMessage.OrganizationId.Value,
eventMessage.ActingUserId.Value).Returns(actingUser);
// Capture the factory function passed to the cache
Func<FusionCacheFactoryExecutionContext<OrganizationUserUserDetails?>, CancellationToken, Task<OrganizationUserUserDetails?>>? capturedFactory = null;
cache.GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Do<Func<FusionCacheFactoryExecutionContext<OrganizationUserUserDetails?>, CancellationToken, Task<OrganizationUserUserDetails?>>>(f => capturedFactory = f)
).Returns(actingUser);
await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithActingUser);
Assert.NotNull(capturedFactory);
var result = await capturedFactory(null!, CancellationToken.None);
await organizationUserRepository.Received(1).GetDetailsByOrganizationIdUserIdAsync(
eventMessage.OrganizationId.Value,
eventMessage.ActingUserId.Value);
Assert.Equal(actingUser, result);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_GroupIdPresent_UsesCache(EventMessage eventMessage, Group group)
{
@@ -211,6 +249,32 @@ public class EventIntegrationHandlerTests
Assert.Null(context.Group);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_GroupFactory_CallsGroupRepository(EventMessage eventMessage, Group group)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithGroup));
var cache = sutProvider.GetDependency<IFusionCache>();
var groupRepository = sutProvider.GetDependency<IGroupRepository>();
eventMessage.GroupId ??= Guid.NewGuid();
groupRepository.GetByIdAsync(eventMessage.GroupId.Value).Returns(group);
// Capture the factory function passed to the cache
Func<FusionCacheFactoryExecutionContext<Group?>, CancellationToken, Task<Group?>>? capturedFactory = null;
cache.GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Do<Func<FusionCacheFactoryExecutionContext<Group?>, CancellationToken, Task<Group?>>>(f => capturedFactory = f)
).Returns(group);
await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithGroup);
Assert.NotNull(capturedFactory);
var result = await capturedFactory(null!, CancellationToken.None);
await groupRepository.Received(1).GetByIdAsync(eventMessage.GroupId.Value);
Assert.Equal(group, result);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_OrganizationIdPresent_UsesCache(EventMessage eventMessage, Organization organization)
{
@@ -250,6 +314,32 @@ public class EventIntegrationHandlerTests
Assert.Null(context.Organization);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_OrganizationFactory_CallsOrganizationRepository(EventMessage eventMessage, Organization organization)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization));
var cache = sutProvider.GetDependency<IFusionCache>();
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
eventMessage.OrganizationId ??= Guid.NewGuid();
organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value).Returns(organization);
// Capture the factory function passed to the cache
Func<FusionCacheFactoryExecutionContext<Organization?>, CancellationToken, Task<Organization?>>? capturedFactory = null;
cache.GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Do<Func<FusionCacheFactoryExecutionContext<Organization?>, CancellationToken, Task<Organization?>>>(f => capturedFactory = f)
).Returns(organization);
await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithOrganization);
Assert.NotNull(capturedFactory);
var result = await capturedFactory(null!, CancellationToken.None);
await organizationRepository.Received(1).GetByIdAsync(eventMessage.OrganizationId.Value);
Assert.Equal(organization, result);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_UserIdPresent_UsesCache(EventMessage eventMessage, OrganizationUserUserDetails userDetails)
{
@@ -313,6 +403,38 @@ public class EventIntegrationHandlerTests
Assert.Null(context.User);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_UserFactory_CallsOrganizationUserRepository(EventMessage eventMessage, OrganizationUserUserDetails userDetails)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser));
var cache = sutProvider.GetDependency<IFusionCache>();
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
eventMessage.OrganizationId ??= Guid.NewGuid();
eventMessage.UserId ??= Guid.NewGuid();
organizationUserRepository.GetDetailsByOrganizationIdUserIdAsync(
eventMessage.OrganizationId.Value,
eventMessage.UserId.Value).Returns(userDetails);
// Capture the factory function passed to the cache
Func<FusionCacheFactoryExecutionContext<OrganizationUserUserDetails?>, CancellationToken, Task<OrganizationUserUserDetails?>>? capturedFactory = null;
cache.GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Do<Func<FusionCacheFactoryExecutionContext<OrganizationUserUserDetails?>, CancellationToken, Task<OrganizationUserUserDetails?>>>(f => capturedFactory = f)
).Returns(userDetails);
await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithUser);
Assert.NotNull(capturedFactory);
var result = await capturedFactory(null!, CancellationToken.None);
await organizationUserRepository.Received(1).GetDetailsByOrganizationIdUserIdAsync(
eventMessage.OrganizationId.Value,
eventMessage.UserId.Value);
Assert.Equal(userDetails, result);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_NoSpecialTokens_DoesNotCallAnyCache(EventMessage eventMessage)
{
@@ -344,6 +466,12 @@ public class EventIntegrationHandlerTests
public async Task HandleEventAsync_BaseTemplateNoConfigurations_DoesNothing(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(NoConfigurations());
var cache = sutProvider.GetDependency<IFusionCache>();
cache.GetOrSetAsync<List<OrganizationIntegrationConfigurationDetails>>(
Arg.Any<string>(),
Arg.Any<Func<object, CancellationToken, Task<List<OrganizationIntegrationConfigurationDetails>>>>(),
Arg.Any<FusionCacheEntryOptions>()
).Returns(NoConfigurations());
await sutProvider.Sut.HandleEventAsync(eventMessage);
Assert.Empty(_eventIntegrationPublisher.ReceivedCalls());
@@ -362,8 +490,8 @@ public class EventIntegrationHandlerTests
[Theory, BitAutoData]
public async Task HandleEventAsync_BaseTemplateOneConfiguration_PublishesIntegrationMessage(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateBase));
eventMessage.OrganizationId = _organizationId;
var sutProvider = GetSutProvider(OneConfiguration(_templateBase));
await sutProvider.Sut.HandleEventAsync(eventMessage);
@@ -382,8 +510,8 @@ public class EventIntegrationHandlerTests
[Theory, BitAutoData]
public async Task HandleEventAsync_BaseTemplateTwoConfigurations_PublishesIntegrationMessages(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(TwoConfigurations(_templateBase));
eventMessage.OrganizationId = _organizationId;
var sutProvider = GetSutProvider(TwoConfigurations(_templateBase));
await sutProvider.Sut.HandleEventAsync(eventMessage);
@@ -405,6 +533,7 @@ public class EventIntegrationHandlerTests
[Theory, BitAutoData]
public async Task HandleEventAsync_FilterReturnsFalse_DoesNothing(EventMessage eventMessage)
{
eventMessage.OrganizationId = _organizationId;
var sutProvider = GetSutProvider(ValidFilterConfiguration());
sutProvider.GetDependency<IIntegrationFilterService>().EvaluateFilterGroup(
Arg.Any<IntegrationFilterGroup>(), Arg.Any<EventMessage>()).Returns(false);
@@ -416,10 +545,10 @@ public class EventIntegrationHandlerTests
[Theory, BitAutoData]
public async Task HandleEventAsync_FilterReturnsTrue_PublishesIntegrationMessage(EventMessage eventMessage)
{
eventMessage.OrganizationId = _organizationId;
var sutProvider = GetSutProvider(ValidFilterConfiguration());
sutProvider.GetDependency<IIntegrationFilterService>().EvaluateFilterGroup(
Arg.Any<IntegrationFilterGroup>(), Arg.Any<EventMessage>()).Returns(true);
eventMessage.OrganizationId = _organizationId;
await sutProvider.Sut.HandleEventAsync(eventMessage);
@@ -435,6 +564,7 @@ public class EventIntegrationHandlerTests
[Theory, BitAutoData]
public async Task HandleEventAsync_InvalidFilter_LogsErrorDoesNothing(EventMessage eventMessage)
{
eventMessage.OrganizationId = _organizationId;
var sutProvider = GetSutProvider(InvalidFilterConfiguration());
await sutProvider.Sut.HandleEventAsync(eventMessage);
@@ -444,12 +574,13 @@ public class EventIntegrationHandlerTests
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<JsonException>(),
Arg.Any<Func<object, Exception, string>>());
Arg.Any<Func<object, Exception?, string>>());
}
[Theory, BitAutoData]
public async Task HandleManyEventsAsync_BaseTemplateNoConfigurations_DoesNothing(List<EventMessage> eventMessages)
{
eventMessages.ForEach(e => e.OrganizationId = _organizationId);
var sutProvider = GetSutProvider(NoConfigurations());
await sutProvider.Sut.HandleManyEventsAsync(eventMessages);
@@ -459,13 +590,14 @@ public class EventIntegrationHandlerTests
[Theory, BitAutoData]
public async Task HandleManyEventsAsync_BaseTemplateOneConfiguration_PublishesIntegrationMessages(List<EventMessage> eventMessages)
{
eventMessages.ForEach(e => e.OrganizationId = _organizationId);
var sutProvider = GetSutProvider(OneConfiguration(_templateBase));
await sutProvider.Sut.HandleManyEventsAsync(eventMessages);
foreach (var eventMessage in eventMessages)
{
var expectedMessage = EventIntegrationHandlerTests.ExpectedMessage(
var expectedMessage = ExpectedMessage(
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"
);
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(
@@ -477,13 +609,14 @@ public class EventIntegrationHandlerTests
public async Task HandleManyEventsAsync_BaseTemplateTwoConfigurations_PublishesIntegrationMessages(
List<EventMessage> eventMessages)
{
eventMessages.ForEach(e => e.OrganizationId = _organizationId);
var sutProvider = GetSutProvider(TwoConfigurations(_templateBase));
await sutProvider.Sut.HandleManyEventsAsync(eventMessages);
foreach (var eventMessage in eventMessages)
{
var expectedMessage = EventIntegrationHandlerTests.ExpectedMessage(
var expectedMessage = ExpectedMessage(
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"
);
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(AssertHelper.AssertPropertyEqual(
@@ -494,4 +627,84 @@ public class EventIntegrationHandlerTests
expectedMessage, new[] { "MessageId", "OrganizationId" })));
}
}
[Theory, BitAutoData]
public async Task HandleEventAsync_CapturedFactories_CallConfigurationRepository(EventMessage eventMessage)
{
eventMessage.OrganizationId = _organizationId;
var sutProvider = GetSutProvider(NoConfigurations());
var cache = sutProvider.GetDependency<IFusionCache>();
var configurationRepository = sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>();
var configs = OneConfiguration(_templateBase);
configurationRepository.GetManyByEventTypeOrganizationIdIntegrationType(eventType: eventMessage.Type, organizationId: _organizationId, integrationType: IntegrationType.Webhook).Returns(configs);
// Capture the factory function - there will be 1 call that returns both specific and wildcard matches
Func<FusionCacheFactoryExecutionContext<List<OrganizationIntegrationConfigurationDetails>>, CancellationToken, Task<List<OrganizationIntegrationConfigurationDetails>>>? capturedFactory = null;
cache.GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Do<Func<FusionCacheFactoryExecutionContext<List<OrganizationIntegrationConfigurationDetails>>, CancellationToken, Task<List<OrganizationIntegrationConfigurationDetails>>>>(f
=> capturedFactory = f),
options: Arg.Any<FusionCacheEntryOptions>(),
tags: Arg.Any<IEnumerable<string>>()
).Returns(new List<OrganizationIntegrationConfigurationDetails>());
await sutProvider.Sut.HandleEventAsync(eventMessage);
// Verify factory was captured
Assert.NotNull(capturedFactory);
// Execute the captured factory to trigger repository call
await capturedFactory(null!, CancellationToken.None);
await configurationRepository.Received(1).GetManyByEventTypeOrganizationIdIntegrationType(eventType: eventMessage.Type, organizationId: _organizationId, integrationType: IntegrationType.Webhook);
}
[Theory, BitAutoData]
public async Task HandleEventAsync_ConfigurationCacheOptions_SetsDurationToConstant(EventMessage eventMessage)
{
eventMessage.OrganizationId = _organizationId;
var sutProvider = GetSutProvider(NoConfigurations());
var cache = sutProvider.GetDependency<IFusionCache>();
FusionCacheEntryOptions? capturedOption = null;
cache.GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<List<OrganizationIntegrationConfigurationDetails>>, CancellationToken, Task<List<OrganizationIntegrationConfigurationDetails>>>>(),
options: Arg.Do<FusionCacheEntryOptions>(opt => capturedOption = opt),
tags: Arg.Any<IEnumerable<string>?>()
).Returns(new List<OrganizationIntegrationConfigurationDetails>());
await sutProvider.Sut.HandleEventAsync(eventMessage);
Assert.NotNull(capturedOption);
Assert.Equal(EventIntegrationsCacheConstants.DurationForOrganizationIntegrationConfigurationDetails,
capturedOption.Duration);
}
[Theory, BitAutoData]
public async Task HandleEventAsync_ConfigurationCache_AddsOrganizationIntegrationTag(EventMessage eventMessage)
{
eventMessage.OrganizationId = _organizationId;
var sutProvider = GetSutProvider(NoConfigurations());
var cache = sutProvider.GetDependency<IFusionCache>();
IEnumerable<string>? capturedTags = null;
cache.GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<List<OrganizationIntegrationConfigurationDetails>>, CancellationToken, Task<List<OrganizationIntegrationConfigurationDetails>>>>(),
options: Arg.Any<FusionCacheEntryOptions>(),
tags: Arg.Do<IEnumerable<string>>(t => capturedTags = t)
).Returns(new List<OrganizationIntegrationConfigurationDetails>());
await sutProvider.Sut.HandleEventAsync(eventMessage);
var expectedTag = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
_organizationId,
IntegrationType.Webhook
);
Assert.NotNull(capturedTags);
Assert.Contains(expectedTag, capturedTags);
}
}

View File

@@ -1,173 +0,0 @@
#nullable enable
using System.Text.Json;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations;
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 NSubstitute.ExceptionExtensions;
using Xunit;
namespace Bit.Core.Test.Services;
[SutProviderCustomize]
public class IntegrationConfigurationDetailsCacheServiceTests
{
private SutProvider<IntegrationConfigurationDetailsCacheService> GetSutProvider(
List<OrganizationIntegrationConfigurationDetails> configurations)
{
var configurationRepository = Substitute.For<IOrganizationIntegrationConfigurationRepository>();
configurationRepository.GetAllConfigurationDetailsAsync().Returns(configurations);
return new SutProvider<IntegrationConfigurationDetailsCacheService>()
.SetDependency(configurationRepository)
.Create();
}
[Theory, BitAutoData]
public async Task GetConfigurationDetails_SpecificKeyExists_ReturnsExpectedList(OrganizationIntegrationConfigurationDetails config)
{
config.EventType = EventType.Cipher_Created;
var sutProvider = GetSutProvider([config]);
await sutProvider.Sut.RefreshAsync();
var result = sutProvider.Sut.GetConfigurationDetails(
config.OrganizationId,
config.IntegrationType,
EventType.Cipher_Created);
Assert.Single(result);
Assert.Same(config, result[0]);
}
[Theory, BitAutoData]
public async Task GetConfigurationDetails_AllEventsKeyExists_ReturnsExpectedList(OrganizationIntegrationConfigurationDetails config)
{
config.EventType = null;
var sutProvider = GetSutProvider([config]);
await sutProvider.Sut.RefreshAsync();
var result = sutProvider.Sut.GetConfigurationDetails(
config.OrganizationId,
config.IntegrationType,
EventType.Cipher_Created);
Assert.Single(result);
Assert.Same(config, result[0]);
}
[Theory, BitAutoData]
public async Task GetConfigurationDetails_BothSpecificAndAllEventsKeyExists_ReturnsExpectedList(
OrganizationIntegrationConfigurationDetails specificConfig,
OrganizationIntegrationConfigurationDetails allKeysConfig
)
{
specificConfig.EventType = EventType.Cipher_Created;
allKeysConfig.EventType = null;
allKeysConfig.OrganizationId = specificConfig.OrganizationId;
allKeysConfig.IntegrationType = specificConfig.IntegrationType;
var sutProvider = GetSutProvider([specificConfig, allKeysConfig]);
await sutProvider.Sut.RefreshAsync();
var result = sutProvider.Sut.GetConfigurationDetails(
specificConfig.OrganizationId,
specificConfig.IntegrationType,
EventType.Cipher_Created);
Assert.Equal(2, result.Count);
Assert.Contains(result, r => r.Template == specificConfig.Template);
Assert.Contains(result, r => r.Template == allKeysConfig.Template);
}
[Theory, BitAutoData]
public async Task GetConfigurationDetails_KeyMissing_ReturnsEmptyList(OrganizationIntegrationConfigurationDetails config)
{
var sutProvider = GetSutProvider([config]);
await sutProvider.Sut.RefreshAsync();
var result = sutProvider.Sut.GetConfigurationDetails(
Guid.NewGuid(),
config.IntegrationType,
config.EventType ?? EventType.Cipher_Created);
Assert.Empty(result);
}
[Theory, BitAutoData]
public async Task GetConfigurationDetails_ReturnsCachedValue_EvenIfRepositoryChanges(OrganizationIntegrationConfigurationDetails config)
{
var sutProvider = GetSutProvider([config]);
await sutProvider.Sut.RefreshAsync();
var newConfig = JsonSerializer.Deserialize<OrganizationIntegrationConfigurationDetails>(JsonSerializer.Serialize(config));
Assert.NotNull(newConfig);
newConfig.Template = "Changed";
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().GetAllConfigurationDetailsAsync()
.Returns([newConfig]);
var result = sutProvider.Sut.GetConfigurationDetails(
config.OrganizationId,
config.IntegrationType,
config.EventType ?? EventType.Cipher_Created);
Assert.Single(result);
Assert.NotEqual("Changed", result[0].Template); // should not yet pick up change from repository
await sutProvider.Sut.RefreshAsync(); // Pick up changes
result = sutProvider.Sut.GetConfigurationDetails(
config.OrganizationId,
config.IntegrationType,
config.EventType ?? EventType.Cipher_Created);
Assert.Single(result);
Assert.Equal("Changed", result[0].Template); // Should have the new value
}
[Theory, BitAutoData]
public async Task RefreshAsync_GroupsByCompositeKey(OrganizationIntegrationConfigurationDetails config1)
{
var config2 = JsonSerializer.Deserialize<OrganizationIntegrationConfigurationDetails>(
JsonSerializer.Serialize(config1))!;
config2.Template = "Another";
var sutProvider = GetSutProvider([config1, config2]);
await sutProvider.Sut.RefreshAsync();
var results = sutProvider.Sut.GetConfigurationDetails(
config1.OrganizationId,
config1.IntegrationType,
config1.EventType ?? EventType.Cipher_Created);
Assert.Equal(2, results.Count);
Assert.Contains(results, r => r.Template == config1.Template);
Assert.Contains(results, r => r.Template == config2.Template);
}
[Theory, BitAutoData]
public async Task RefreshAsync_LogsInformationOnSuccess(OrganizationIntegrationConfigurationDetails config)
{
var sutProvider = GetSutProvider([config]);
await sutProvider.Sut.RefreshAsync();
sutProvider.GetDependency<ILogger<IntegrationConfigurationDetailsCacheService>>().Received().Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Is<object>(o => o.ToString()!.Contains("Refreshed successfully")),
null,
Arg.Any<Func<object, Exception?, string>>());
}
[Fact]
public async Task RefreshAsync_OnException_LogsError()
{
var sutProvider = GetSutProvider([]);
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().GetAllConfigurationDetailsAsync()
.Throws(new Exception("Database failure"));
await sutProvider.Sut.RefreshAsync();
sutProvider.GetDependency<ILogger<IntegrationConfigurationDetailsCacheService>>().Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o => o.ToString()!.Contains("Refresh failed")),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception?, string>>());
}
}

View File

@@ -2,7 +2,6 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
@@ -14,7 +13,6 @@ using Bit.Core.Auth.Services;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
@@ -342,26 +340,26 @@ public class SsoConfigServiceTests
await sutProvider.Sut.SaveAsync(ssoConfig, organization);
await sutProvider.GetDependency<ISavePolicyCommand>().Received(1)
await sutProvider.GetDependency<IVNextSavePolicyCommand>().Received(1)
.SaveAsync(
Arg.Is<PolicyUpdate>(t => t.Type == PolicyType.SingleOrg &&
t.OrganizationId == organization.Id &&
t.Enabled)
Arg.Is<SavePolicyModel>(t => t.PolicyUpdate.Type == PolicyType.SingleOrg &&
t.PolicyUpdate.OrganizationId == organization.Id &&
t.PolicyUpdate.Enabled)
);
await sutProvider.GetDependency<ISavePolicyCommand>().Received(1)
await sutProvider.GetDependency<IVNextSavePolicyCommand>().Received(1)
.SaveAsync(
Arg.Is<PolicyUpdate>(t => t.Type == PolicyType.ResetPassword &&
t.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled &&
t.OrganizationId == organization.Id &&
t.Enabled)
Arg.Is<SavePolicyModel>(t => t.PolicyUpdate.Type == PolicyType.ResetPassword &&
t.PolicyUpdate.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled &&
t.PolicyUpdate.OrganizationId == organization.Id &&
t.PolicyUpdate.Enabled)
);
await sutProvider.GetDependency<ISavePolicyCommand>().Received(1)
await sutProvider.GetDependency<IVNextSavePolicyCommand>().Received(1)
.SaveAsync(
Arg.Is<PolicyUpdate>(t => t.Type == PolicyType.RequireSso &&
t.OrganizationId == organization.Id &&
t.Enabled)
Arg.Is<SavePolicyModel>(t => t.PolicyUpdate.Type == PolicyType.RequireSso &&
t.PolicyUpdate.OrganizationId == organization.Id &&
t.PolicyUpdate.Enabled)
);
await sutProvider.GetDependency<ISsoConfigRepository>().ReceivedWithAnyArgs()
@@ -369,7 +367,7 @@ public class SsoConfigServiceTests
}
[Theory, BitAutoData]
public async Task SaveAsync_Tde_WhenPolicyValidatorsRefactorEnabled_UsesVNextSavePolicyCommand(
public async Task SaveAsync_Tde_UsesVNextSavePolicyCommand(
SutProvider<SsoConfigService> sutProvider, Organization organization)
{
var ssoConfig = new SsoConfig
@@ -383,10 +381,6 @@ public class SsoConfigServiceTests
OrganizationId = organization.Id,
};
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)
.Returns(true);
await sutProvider.Sut.SaveAsync(ssoConfig, organization);
await sutProvider.GetDependency<IVNextSavePolicyCommand>()

View File

@@ -1017,6 +1017,7 @@ public class RegisterUserCommandTests
[Theory]
[BitAutoData(PlanType.FamiliesAnnually)]
[BitAutoData(PlanType.FamiliesAnnually2019)]
[BitAutoData(PlanType.FamiliesAnnually2025)]
[BitAutoData(PlanType.Free)]
public async Task SendWelcomeEmail_FamilyOrg_SendsFamilyWelcomeEmail(
PlanType planType,

View File

@@ -1,4 +1,5 @@
using Bit.Core.Utilities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
@@ -11,8 +12,12 @@ public class EventIntegrationsCacheConstantsTests
{
var expected = $"Group:{groupId:N}";
var key = EventIntegrationsCacheConstants.BuildCacheKeyForGroup(groupId);
var keyWithDifferentGroup = EventIntegrationsCacheConstants.BuildCacheKeyForGroup(Guid.NewGuid());
var keyWithSameGroup = EventIntegrationsCacheConstants.BuildCacheKeyForGroup(groupId);
Assert.Equal(expected, key);
Assert.NotEqual(key, keyWithDifferentGroup);
Assert.Equal(key, keyWithSameGroup);
}
[Theory, BitAutoData]
@@ -20,8 +25,69 @@ public class EventIntegrationsCacheConstantsTests
{
var expected = $"Organization:{orgId:N}";
var key = EventIntegrationsCacheConstants.BuildCacheKeyForOrganization(orgId);
var keyWithDifferentOrg = EventIntegrationsCacheConstants.BuildCacheKeyForOrganization(Guid.NewGuid());
var keyWithSameOrg = EventIntegrationsCacheConstants.BuildCacheKeyForOrganization(orgId);
Assert.Equal(expected, key);
Assert.NotEqual(key, keyWithDifferentOrg);
Assert.Equal(key, keyWithSameOrg);
}
[Theory, BitAutoData]
public void BuildCacheKeyForOrganizationIntegrationConfigurationDetails_ReturnsExpectedKey(Guid orgId)
{
var integrationType = IntegrationType.Hec;
var expectedWithEvent = $"OrganizationIntegrationConfigurationDetails:{orgId:N}:Hec:User_LoggedIn";
var keyWithEvent = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
orgId, integrationType, EventType.User_LoggedIn);
var keyWithDifferentEvent = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
orgId, integrationType, EventType.Cipher_Created);
var keyWithDifferentIntegration = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
orgId, IntegrationType.Webhook, EventType.User_LoggedIn);
var keyWithDifferentOrganization = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
Guid.NewGuid(), integrationType, EventType.User_LoggedIn);
var keyWithSameDetails = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
orgId, integrationType, EventType.User_LoggedIn);
Assert.Equal(expectedWithEvent, keyWithEvent);
Assert.NotEqual(keyWithEvent, keyWithDifferentEvent);
Assert.NotEqual(keyWithEvent, keyWithDifferentIntegration);
Assert.NotEqual(keyWithEvent, keyWithDifferentOrganization);
Assert.Equal(keyWithEvent, keyWithSameDetails);
var expectedWithNullEvent = $"OrganizationIntegrationConfigurationDetails:{orgId:N}:Hec:";
var keyWithNullEvent = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
orgId, integrationType, null);
var keyWithNullEventDifferentIntegration = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
orgId, IntegrationType.Webhook, null);
var keyWithNullEventDifferentOrganization = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
Guid.NewGuid(), integrationType, null);
Assert.Equal(expectedWithNullEvent, keyWithNullEvent);
Assert.NotEqual(keyWithEvent, keyWithNullEvent);
Assert.NotEqual(keyWithNullEvent, keyWithDifferentEvent);
Assert.NotEqual(keyWithNullEvent, keyWithNullEventDifferentIntegration);
Assert.NotEqual(keyWithNullEvent, keyWithNullEventDifferentOrganization);
}
[Theory, BitAutoData]
public void BuildCacheTagForOrganizationIntegration_ReturnsExpectedKey(Guid orgId)
{
var expected = $"OrganizationIntegration:{orgId:N}:Hec";
var tag = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
orgId, IntegrationType.Hec);
var tagWithDifferentOrganization = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
Guid.NewGuid(), IntegrationType.Hec);
var tagWithDifferentIntegrationType = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
orgId, IntegrationType.Webhook);
var tagWithSameDetails = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
orgId, IntegrationType.Hec);
Assert.Equal(expected, tag);
Assert.NotEqual(tag, tagWithDifferentOrganization);
Assert.NotEqual(tag, tagWithDifferentIntegrationType);
Assert.Equal(tag, tagWithSameDetails);
}
[Theory, BitAutoData]
@@ -29,8 +95,14 @@ public class EventIntegrationsCacheConstantsTests
{
var expected = $"OrganizationUserUserDetails:{orgId:N}:{userId:N}";
var key = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(orgId, userId);
var keyWithDifferentOrg = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(Guid.NewGuid(), userId);
var keyWithDifferentUser = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(orgId, Guid.NewGuid());
var keyWithSameDetails = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(orgId, userId);
Assert.Equal(expected, key);
Assert.NotEqual(key, keyWithDifferentOrg);
Assert.NotEqual(key, keyWithDifferentUser);
Assert.Equal(key, keyWithSameDetails);
}
[Fact]
@@ -38,4 +110,13 @@ public class EventIntegrationsCacheConstantsTests
{
Assert.Equal("EventIntegrations", EventIntegrationsCacheConstants.CacheName);
}
[Fact]
public void DurationForOrganizationIntegrationConfigurationDetails_ReturnsExpected()
{
Assert.Equal(
TimeSpan.FromDays(1),
EventIntegrationsCacheConstants.DurationForOrganizationIntegrationConfigurationDetails
);
}
}

View File

@@ -7,6 +7,7 @@ using NSubstitute;
using StackExchange.Redis;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
using ZiggyCreatures.Caching.Fusion.Backplane;
namespace Bit.Core.Test.Utilities;
@@ -167,7 +168,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests
var settings = CreateGlobalSettings(new()
{
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
{ "GlobalSettings:DistributedCache:DefaultExtendedCache:UseSharedRedisCache", "true" }
{ "GlobalSettings:DistributedCache:DefaultExtendedCache:UseSharedDistributedCache", "true" }
});
// Provide a multiplexer (shared)
@@ -187,7 +188,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests
{
var settings = new GlobalSettings.ExtendedCacheSettings
{
UseSharedRedisCache = false,
UseSharedDistributedCache = false,
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "invalid:9999" }
};
@@ -242,7 +243,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests
{
var settings = new GlobalSettings.ExtendedCacheSettings
{
UseSharedRedisCache = false,
UseSharedDistributedCache = false,
// No Redis connection string
};
@@ -261,13 +262,13 @@ public class ExtendedCacheServiceCollectionExtensionsTests
var settingsA = new GlobalSettings.ExtendedCacheSettings
{
EnableDistributedCache = true,
UseSharedRedisCache = false,
UseSharedDistributedCache = false,
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" }
};
var settingsB = new GlobalSettings.ExtendedCacheSettings
{
EnableDistributedCache = true,
UseSharedRedisCache = false,
UseSharedDistributedCache = false,
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6380" }
};
@@ -294,7 +295,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests
var settings = new GlobalSettings.ExtendedCacheSettings
{
UseSharedRedisCache = false,
UseSharedDistributedCache = false,
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" }
};
@@ -306,6 +307,180 @@ public class ExtendedCacheServiceCollectionExtensionsTests
Assert.Same(existingCache, resolved);
}
[Fact]
public void AddExtendedCache_SharedNonRedisCache_UsesDistributedCacheWithoutBackplane()
{
var settings = new GlobalSettings.ExtendedCacheSettings
{
UseSharedDistributedCache = true,
EnableDistributedCache = true,
// No Redis.ConnectionString
};
// Register non-Redis distributed cache
_services.AddSingleton(Substitute.For<IDistributedCache>());
_services.AddExtendedCache(_cacheName, _globalSettings, settings);
using var provider = _services.BuildServiceProvider();
var cache = provider.GetRequiredKeyedService<IFusionCache>(_cacheName);
Assert.True(cache.HasDistributedCache);
Assert.False(cache.HasBackplane); // No backplane for non-Redis
}
[Fact]
public void AddExtendedCache_SharedRedisWithMockedMultiplexer_ReusesExistingMultiplexer()
{
// Override GlobalSettings to include Redis connection string
var globalSettings = CreateGlobalSettings(new()
{
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" }
});
// Custom settings for this cache
var settings = new GlobalSettings.ExtendedCacheSettings
{
UseSharedDistributedCache = true,
EnableDistributedCache = true,
};
// Pre-register mocked multiplexer (simulates AddDistributedCache already called)
var mockMultiplexer = Substitute.For<IConnectionMultiplexer>();
_services.AddSingleton(mockMultiplexer);
_services.AddExtendedCache(_cacheName, globalSettings, settings);
using var provider = _services.BuildServiceProvider();
var cache = provider.GetRequiredKeyedService<IFusionCache>(_cacheName);
Assert.True(cache.HasDistributedCache);
Assert.True(cache.HasBackplane);
// Verify same multiplexer was reused (TryAdd didn't replace it)
var resolvedMux = provider.GetRequiredService<IConnectionMultiplexer>();
Assert.Same(mockMultiplexer, resolvedMux);
}
[Fact]
public void AddExtendedCache_KeyedNonRedisCache_UsesKeyedDistributedCacheWithoutBackplane()
{
var settings = new GlobalSettings.ExtendedCacheSettings
{
UseSharedDistributedCache = false,
EnableDistributedCache = true,
// No Redis.ConnectionString
};
// Register keyed non-Redis distributed cache
_services.AddKeyedSingleton(_cacheName, Substitute.For<IDistributedCache>());
_services.AddExtendedCache(_cacheName, _globalSettings, settings);
using var provider = _services.BuildServiceProvider();
var cache = provider.GetRequiredKeyedService<IFusionCache>(_cacheName);
Assert.True(cache.HasDistributedCache);
Assert.False(cache.HasBackplane);
}
[Fact]
public void AddExtendedCache_KeyedRedisWithConnectionString_CreatesIsolatedInfrastructure()
{
var settings = new GlobalSettings.ExtendedCacheSettings
{
UseSharedDistributedCache = false,
EnableDistributedCache = true,
Redis = new GlobalSettings.ConnectionStringSettings
{
ConnectionString = "localhost:6379"
}
};
// Pre-register mocked keyed multiplexer to avoid connection attempt
_services.AddKeyedSingleton(_cacheName, Substitute.For<IConnectionMultiplexer>());
_services.AddExtendedCache(_cacheName, _globalSettings, settings);
using var provider = _services.BuildServiceProvider();
var cache = provider.GetRequiredKeyedService<IFusionCache>(_cacheName);
Assert.True(cache.HasDistributedCache);
Assert.True(cache.HasBackplane);
// Verify keyed services exist
var keyedMux = provider.GetRequiredKeyedService<IConnectionMultiplexer>(_cacheName);
Assert.NotNull(keyedMux);
var keyedRedis = provider.GetRequiredKeyedService<IDistributedCache>(_cacheName);
Assert.NotNull(keyedRedis);
var keyedBackplane = provider.GetRequiredKeyedService<IFusionCacheBackplane>(_cacheName);
Assert.NotNull(keyedBackplane);
}
[Fact]
public void AddExtendedCache_NoDistributedCacheRegistered_WorksWithMemoryOnly()
{
var settings = new GlobalSettings.ExtendedCacheSettings
{
UseSharedDistributedCache = true,
EnableDistributedCache = true,
// No Redis connection string, no IDistributedCache registered
// This is technically a misconfiguration, but we handle it without failing
};
_services.AddExtendedCache(_cacheName, _globalSettings, settings);
using var provider = _services.BuildServiceProvider();
var cache = provider.GetRequiredKeyedService<IFusionCache>(_cacheName);
Assert.False(cache.HasDistributedCache);
Assert.False(cache.HasBackplane);
// Verify L1 memory cache still works
cache.Set("key", "value");
var result = cache.GetOrDefault<string>("key");
Assert.Equal("value", result);
}
[Fact]
public void AddExtendedCache_MultipleKeyedCachesWithDifferentTypes_EachHasCorrectConfig()
{
var redisSettings = new GlobalSettings.ExtendedCacheSettings
{
UseSharedDistributedCache = false,
EnableDistributedCache = true,
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" }
};
var nonRedisSettings = new GlobalSettings.ExtendedCacheSettings
{
UseSharedDistributedCache = false,
EnableDistributedCache = true,
// No Redis connection string
};
// Setup Cache1 (Redis)
_services.AddKeyedSingleton("Cache1", Substitute.For<IConnectionMultiplexer>());
_services.AddExtendedCache("Cache1", _globalSettings, redisSettings);
// Setup Cache2 (non-Redis)
_services.AddKeyedSingleton("Cache2", Substitute.For<IDistributedCache>());
_services.AddExtendedCache("Cache2", _globalSettings, nonRedisSettings);
using var provider = _services.BuildServiceProvider();
var cache1 = provider.GetRequiredKeyedService<IFusionCache>("Cache1");
var cache2 = provider.GetRequiredKeyedService<IFusionCache>("Cache2");
Assert.True(cache1.HasDistributedCache);
Assert.True(cache1.HasBackplane);
Assert.True(cache2.HasDistributedCache);
Assert.False(cache2.HasBackplane);
Assert.NotSame(cache1, cache2);
}
private static GlobalSettings CreateGlobalSettings(Dictionary<string, string?> data)
{
var config = new ConfigurationBuilder()

View File

@@ -74,7 +74,7 @@ public class NormalCipherPermissionTests
var cipherDetails = new CipherDetails { UserId = null, OrganizationId = Guid.NewGuid() };
// Act
var exception = Assert.Throws<Exception>(() => NormalCipherPermissions.CanDelete(user, cipherDetails, organizationAbility));
var exception = Assert.Throws<Exception>(() => NormalCipherPermissions.CanRestore(user, cipherDetails, organizationAbility));
// Assert
Assert.Equal("Cipher does not belong to the input organization.", exception.Message);
@@ -92,11 +92,11 @@ public class NormalCipherPermissionTests
// Arrange
var user = new User { Id = Guid.Empty };
var organizationId = Guid.NewGuid();
var cipherDetails = new CipherDetails { Manage = manage, Edit = edit, UserId = null, OrganizationId = organizationId };
var cipherDetails = new CipherDetails { Manage = manage, Edit = edit, UserId = user.Id, OrganizationId = organizationId };
var organizationAbility = new OrganizationAbility { Id = organizationId, LimitItemDeletion = limitItemDeletion };
// Act
var result = NormalCipherPermissions.CanRestore(user, cipherDetails, organizationAbility);
var result = NormalCipherPermissions.CanDelete(user, cipherDetails, organizationAbility);
// Assert
Assert.Equal(result, expectedResult);

View File

@@ -523,10 +523,6 @@ public class IdentityServerSsoTests
var keyConnectorUrl = AssertHelper.AssertJsonProperty(keyConnectorOption, "KeyConnectorUrl", JsonValueKind.String).GetString();
Assert.Equal("https://key_connector.com", keyConnectorUrl);
// For backwards compatibility reasons the url should also be on the root
keyConnectorUrl = AssertHelper.AssertJsonProperty(root, "KeyConnectorUrl", JsonValueKind.String).GetString();
Assert.Equal("https://key_connector.com", keyConnectorUrl);
}
private static async Task<JsonDocument> RunSuccessTestAsync(MemberDecryptionType memberDecryptionType)

View File

@@ -89,6 +89,286 @@ public class ProviderUserRepositoryTests
Assert.Equal(serializedSsoConfigData, orgWithSsoDetails.SsoConfig);
}
[Theory, DatabaseData]
public async Task GetManyByManyUsersAsync_WithMultipleUsers_ReturnsAllProviderUsers(
IUserRepository userRepository,
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository)
{
var user1 = await userRepository.CreateTestUserAsync();
var user2 = await userRepository.CreateTestUserAsync();
var user3 = await userRepository.CreateTestUserAsync();
var provider1 = await providerRepository.CreateAsync(new Provider
{
Name = "Test Provider 1",
Enabled = true,
Type = ProviderType.Msp
});
var provider2 = await providerRepository.CreateAsync(new Provider
{
Name = "Test Provider 2",
Enabled = true,
Type = ProviderType.Reseller
});
var providerUser1 = await providerUserRepository.CreateAsync(new ProviderUser
{
Id = Guid.NewGuid(),
ProviderId = provider1.Id,
UserId = user1.Id,
Status = ProviderUserStatusType.Confirmed,
Type = ProviderUserType.ProviderAdmin
});
var providerUser2 = await providerUserRepository.CreateAsync(new ProviderUser
{
Id = Guid.NewGuid(),
ProviderId = provider1.Id,
UserId = user2.Id,
Status = ProviderUserStatusType.Invited,
Type = ProviderUserType.ServiceUser
});
var providerUser3 = await providerUserRepository.CreateAsync(new ProviderUser
{
Id = Guid.NewGuid(),
ProviderId = provider2.Id,
UserId = user3.Id,
Status = ProviderUserStatusType.Confirmed,
Type = ProviderUserType.ProviderAdmin
});
var userIds = new[] { user1.Id, user2.Id, user3.Id };
var results = (await providerUserRepository.GetManyByManyUsersAsync(userIds)).ToList();
Assert.Equal(3, results.Count);
Assert.Contains(results, pu => pu.Id == providerUser1.Id && pu.UserId == user1.Id);
Assert.Contains(results, pu => pu.Id == providerUser2.Id && pu.UserId == user2.Id);
Assert.Contains(results, pu => pu.Id == providerUser3.Id && pu.UserId == user3.Id);
}
[Theory, DatabaseData]
public async Task GetManyByManyUsersAsync_WithSingleUser_ReturnsSingleProviderUser(
IUserRepository userRepository,
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository)
{
var user = await userRepository.CreateTestUserAsync();
var provider = await providerRepository.CreateAsync(new Provider
{
Name = "Test Provider",
Enabled = true,
Type = ProviderType.Msp
});
var providerUser = await providerUserRepository.CreateAsync(new ProviderUser
{
Id = Guid.NewGuid(),
ProviderId = provider.Id,
UserId = user.Id,
Status = ProviderUserStatusType.Confirmed,
Type = ProviderUserType.ProviderAdmin
});
var results = (await providerUserRepository.GetManyByManyUsersAsync([user.Id])).ToList();
Assert.Single(results);
Assert.Equal(user.Id, results[0].UserId);
Assert.Equal(provider.Id, results[0].ProviderId);
}
[Theory, DatabaseData]
public async Task GetManyByManyUsersAsync_WithUserHavingMultipleProviders_ReturnsAllProviderUsers(
IUserRepository userRepository,
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository)
{
var user = await userRepository.CreateTestUserAsync();
var provider1 = await providerRepository.CreateAsync(new Provider
{
Name = "Test Provider 1",
Enabled = true,
Type = ProviderType.Msp
});
var provider2 = await providerRepository.CreateAsync(new Provider
{
Name = "Test Provider 2",
Enabled = true,
Type = ProviderType.Reseller
});
var providerUser1 = await providerUserRepository.CreateAsync(new ProviderUser
{
ProviderId = provider1.Id,
UserId = user.Id,
Status = ProviderUserStatusType.Confirmed,
Type = ProviderUserType.ProviderAdmin
});
var providerUser2 = await providerUserRepository.CreateAsync(new ProviderUser
{
ProviderId = provider2.Id,
UserId = user.Id,
Status = ProviderUserStatusType.Confirmed,
Type = ProviderUserType.ServiceUser
});
var results = (await providerUserRepository.GetManyByManyUsersAsync([user.Id])).ToList();
Assert.Equal(2, results.Count);
Assert.Contains(results, pu => pu.Id == providerUser1.Id);
Assert.Contains(results, pu => pu.Id == providerUser2.Id);
}
[Theory, DatabaseData]
public async Task GetManyByManyUsersAsync_WithEmptyUserIds_ReturnsEmpty(
IProviderUserRepository providerUserRepository)
{
var results = await providerUserRepository.GetManyByManyUsersAsync(Array.Empty<Guid>());
Assert.Empty(results);
}
[Theory, DatabaseData]
public async Task GetManyByManyUsersAsync_WithNonExistentUserIds_ReturnsEmpty(
IProviderUserRepository providerUserRepository)
{
var nonExistentUserIds = new[] { Guid.NewGuid(), Guid.NewGuid() };
var results = await providerUserRepository.GetManyByManyUsersAsync(nonExistentUserIds);
Assert.Empty(results);
}
[Theory, DatabaseData]
public async Task GetManyByManyUsersAsync_WithMixedExistentAndNonExistentUserIds_ReturnsOnlyExistent(
IUserRepository userRepository,
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository)
{
var existingUser = await userRepository.CreateTestUserAsync();
var provider = await providerRepository.CreateAsync(new Provider
{
Name = "Test Provider",
Enabled = true,
Type = ProviderType.Msp
});
var providerUser = await providerUserRepository.CreateAsync(new ProviderUser
{
ProviderId = provider.Id,
UserId = existingUser.Id,
Status = ProviderUserStatusType.Confirmed,
Type = ProviderUserType.ProviderAdmin
});
var userIds = new[] { existingUser.Id, Guid.NewGuid(), Guid.NewGuid() };
var results = (await providerUserRepository.GetManyByManyUsersAsync(userIds)).ToList();
Assert.Single(results);
Assert.Equal(existingUser.Id, results[0].UserId);
}
[Theory, DatabaseData]
public async Task GetManyByManyUsersAsync_ReturnsAllStatuses(
IUserRepository userRepository,
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository)
{
var user1 = await userRepository.CreateTestUserAsync();
var user2 = await userRepository.CreateTestUserAsync();
var user3 = await userRepository.CreateTestUserAsync();
var provider = await providerRepository.CreateAsync(new Provider
{
Name = "Test Provider",
Enabled = true,
Type = ProviderType.Msp
});
await providerUserRepository.CreateAsync(new ProviderUser
{
ProviderId = provider.Id,
UserId = user1.Id,
Status = ProviderUserStatusType.Invited,
Type = ProviderUserType.ServiceUser
});
await providerUserRepository.CreateAsync(new ProviderUser
{
ProviderId = provider.Id,
UserId = user2.Id,
Status = ProviderUserStatusType.Accepted,
Type = ProviderUserType.ServiceUser
});
await providerUserRepository.CreateAsync(new ProviderUser
{
ProviderId = provider.Id,
UserId = user3.Id,
Status = ProviderUserStatusType.Confirmed,
Type = ProviderUserType.ProviderAdmin
});
var userIds = new[] { user1.Id, user2.Id, user3.Id };
var results = (await providerUserRepository.GetManyByManyUsersAsync(userIds)).ToList();
Assert.Equal(3, results.Count);
Assert.Contains(results, pu => pu.UserId == user1.Id && pu.Status == ProviderUserStatusType.Invited);
Assert.Contains(results, pu => pu.UserId == user2.Id && pu.Status == ProviderUserStatusType.Accepted);
Assert.Contains(results, pu => pu.UserId == user3.Id && pu.Status == ProviderUserStatusType.Confirmed);
}
[Theory, DatabaseData]
public async Task GetManyByManyUsersAsync_ReturnsAllProviderUserTypes(
IUserRepository userRepository,
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository)
{
var user1 = await userRepository.CreateTestUserAsync();
var user2 = await userRepository.CreateTestUserAsync();
var provider = await providerRepository.CreateAsync(new Provider
{
Name = "Test Provider",
Enabled = true,
Type = ProviderType.Msp
});
await providerUserRepository.CreateAsync(new ProviderUser
{
ProviderId = provider.Id,
UserId = user1.Id,
Status = ProviderUserStatusType.Confirmed,
Type = ProviderUserType.ServiceUser
});
await providerUserRepository.CreateAsync(new ProviderUser
{
ProviderId = provider.Id,
UserId = user2.Id,
Status = ProviderUserStatusType.Confirmed,
Type = ProviderUserType.ProviderAdmin
});
var userIds = new[] { user1.Id, user2.Id };
var results = (await providerUserRepository.GetManyByManyUsersAsync(userIds)).ToList();
Assert.Equal(2, results.Count);
Assert.Contains(results, pu => pu.UserId == user1.Id && pu.Type == ProviderUserType.ServiceUser);
Assert.Contains(results, pu => pu.UserId == user2.Id && pu.Type == ProviderUserType.ProviderAdmin);
}
private static void AssertProviderOrganizationDetails(
ProviderUserOrganizationDetails actual,
Organization expectedOrganization,
@@ -139,4 +419,6 @@ public class ProviderUserRepositoryTests
Assert.Equal(expectedProviderUser.Status, actual.Status);
Assert.Equal(expectedProviderUser.Type, actual.Type);
}
}