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

This commit is contained in:
Patrick-Pimentel-Bitwarden
2026-01-30 19:06:01 -05:00
committed by GitHub
559 changed files with 69161 additions and 4929 deletions

View File

@@ -1,11 +1,14 @@
using System.Net;
using System.Text;
using System.Text.Json;
using AutoMapper;
using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.Models.Request;
using Bit.Core.Entities;
using Bit.Seeder.Recipes;
using Microsoft.AspNetCore.Identity;
using Xunit;
using Xunit.Abstractions;
@@ -26,7 +29,9 @@ public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
@@ -34,8 +39,8 @@ public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper
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 collectionIds = collectionsSeeder.Seed(orgId, collectionCount, orgUserIds, 0);
var groupIds = groupsSeeder.Seed(orgId, 1, orgUserIds, 0);
var groupId = groupIds.First();

View File

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

View File

@@ -5,7 +5,6 @@ using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.Models.Request;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.Repositories;
@@ -14,8 +13,6 @@ using Bit.Core.Entities;
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;
@@ -28,12 +25,6 @@ public class OrganizationUserControllerTests : IClassFixture<ApiApplicationFacto
public OrganizationUserControllerTests(ApiApplicationFactory apiFactory)
{
_factory = apiFactory;
_factory.SubstituteService<IFeatureService>(featureService =>
{
featureService
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
.Returns(true);
});
_client = _factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
}

View File

@@ -1,13 +1,16 @@
using System.Net;
using System.Text;
using System.Text.Json;
using AutoMapper;
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.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Seeder.Recipes;
using Microsoft.AspNetCore.Identity;
using Xunit;
using Xunit.Abstractions;
@@ -28,7 +31,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
@@ -37,8 +42,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
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);
collectionsSeeder.Seed(orgId, 10, orgUserIds);
groupsSeeder.Seed(orgId, 5, orgUserIds);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@@ -64,7 +69,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
@@ -72,8 +79,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
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);
collectionsSeeder.Seed(orgId, 10, orgUserIds);
groupsSeeder.Seed(orgId, 5, orgUserIds);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@@ -98,14 +105,16 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
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]);
groupsSeeder.Seed(orgId, 2, [orgUserId]);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@@ -130,7 +139,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
@@ -163,7 +174,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(
@@ -211,7 +224,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
@@ -251,7 +266,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(
@@ -295,7 +312,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(
@@ -339,7 +358,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domainSeeder = new OrganizationDomainRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
@@ -350,7 +371,7 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
users: userCount,
usersStatus: OrganizationUserStatusType.Confirmed);
domainSeeder.AddVerifiedDomainToOrganization(orgId, domain);
domainSeeder.Seed(orgId, domain);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@@ -384,7 +405,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
@@ -392,8 +415,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
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);
var collectionIds = collectionsSeeder.Seed(orgId, 3, orgUserIds, 0);
var groupIds = groupsSeeder.Seed(orgId, 2, orgUserIds, 0);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@@ -434,7 +457,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
@@ -471,7 +496,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domainSeeder = new OrganizationDomainRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
@@ -481,7 +508,7 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
users: 2,
usersStatus: OrganizationUserStatusType.Confirmed);
domainSeeder.AddVerifiedDomainToOrganization(orgId, domain);
domainSeeder.Seed(orgId, domain);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@@ -512,14 +539,16 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
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);
var collectionIds = collectionsSeeder.Seed(orgId, 2, orgUserIds, 0);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@@ -560,7 +589,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(

View File

@@ -0,0 +1,171 @@
using System.Net;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Xunit;
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
public class OrganizationUsersControllerSelfRevokeTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
{
private readonly HttpClient _client;
private readonly ApiApplicationFactory _factory;
private readonly LoginHelper _loginHelper;
private string _ownerEmail = null!;
public OrganizationUsersControllerSelfRevokeTests(ApiApplicationFactory apiFactory)
{
_factory = apiFactory;
_client = _factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
}
public async Task InitializeAsync()
{
_ownerEmail = $"{Guid.NewGuid()}@example.com";
await _factory.LoginWithNewAccount(_ownerEmail);
}
public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task SelfRevoke_WhenPolicyEnabledAndUserIsEligible_ReturnsOk()
{
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card);
var policy = new Policy
{
OrganizationId = organization.Id,
Type = PolicyType.OrganizationDataOwnership,
Enabled = true,
Data = null
};
await _factory.GetService<IPolicyRepository>().CreateAsync(policy);
var (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory,
organization.Id,
OrganizationUserType.User);
await _loginHelper.LoginAsync(userEmail);
var result = await _client.PutAsync($"organizations/{organization.Id}/users/revoke-self", null);
Assert.Equal(HttpStatusCode.NoContent, result.StatusCode);
var organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
var organizationUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organization.Id);
var revokedUser = organizationUsers.FirstOrDefault(u => u.Email == userEmail);
Assert.NotNull(revokedUser);
Assert.Equal(OrganizationUserStatusType.Revoked, revokedUser.Status);
}
[Fact]
public async Task SelfRevoke_WhenUserNotMemberOfOrganization_ReturnsForbidden()
{
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card);
var policy = new Policy
{
OrganizationId = organization.Id,
Type = PolicyType.OrganizationDataOwnership,
Enabled = true,
Data = null
};
await _factory.GetService<IPolicyRepository>().CreateAsync(policy);
var nonMemberEmail = $"{Guid.NewGuid()}@example.com";
await _factory.LoginWithNewAccount(nonMemberEmail);
await _loginHelper.LoginAsync(nonMemberEmail);
var result = await _client.PutAsync($"organizations/{organization.Id}/users/revoke-self", null);
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
}
[Theory]
[InlineData(OrganizationUserType.Owner)]
[InlineData(OrganizationUserType.Admin)]
public async Task SelfRevoke_WhenUserIsOwnerOrAdmin_ReturnsBadRequest(OrganizationUserType userType)
{
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card);
var policy = new Policy
{
OrganizationId = organization.Id,
Type = PolicyType.OrganizationDataOwnership,
Enabled = true,
Data = null
};
await _factory.GetService<IPolicyRepository>().CreateAsync(policy);
string userEmail;
if (userType == OrganizationUserType.Owner)
{
userEmail = _ownerEmail;
}
else
{
(userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory,
organization.Id,
userType);
}
await _loginHelper.LoginAsync(userEmail);
var result = await _client.PutAsync($"organizations/{organization.Id}/users/revoke-self", null);
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
}
[Fact]
public async Task SelfRevoke_WhenUserIsProviderButNotMember_ReturnsForbidden()
{
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card);
var policy = new Policy
{
OrganizationId = organization.Id,
Type = PolicyType.OrganizationDataOwnership,
Enabled = true,
Data = null
};
await _factory.GetService<IPolicyRepository>().CreateAsync(policy);
var provider = await ProviderTestHelpers.CreateProviderAndLinkToOrganizationAsync(
_factory,
organization.Id,
ProviderType.Msp,
ProviderStatusType.Billable);
var providerUserEmail = $"{Guid.NewGuid()}@example.com";
await _factory.LoginWithNewAccount(providerUserEmail);
await ProviderTestHelpers.CreateProviderUserAsync(
_factory,
provider.Id,
providerUserEmail,
ProviderUserType.ProviderAdmin);
await _loginHelper.LoginAsync(providerUserEmail);
var result = await _client.PutAsync($"organizations/{organization.Id}/users/revoke-self", null);
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
}
}

View File

@@ -1,14 +1,17 @@
using System.Net;
using System.Text;
using System.Text.Json;
using AutoMapper;
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.Entities;
using Bit.Core.Tokens;
using Bit.Seeder.Recipes;
using Microsoft.AspNetCore.Identity;
using Xunit;
using Xunit.Abstractions;
@@ -29,7 +32,9 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
@@ -37,8 +42,8 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
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);
collectionsSeeder.Seed(orgId, collectionCount, orgUserIds, 0);
groupsSeeder.Seed(orgId, groupCount, orgUserIds, 0);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@@ -77,7 +82,9 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
@@ -85,8 +92,8 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
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);
collectionsSeeder.Seed(orgId, collectionCount, orgUserIds, 0);
groupsSeeder.Seed(orgId, groupCount, orgUserIds, 0);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");

View File

@@ -150,8 +150,8 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
Enabled = true,
Data = new Dictionary<string, object>
{
{ "minComplexity", 10 },
{ "minLength", 12 },
{ "minComplexity", 4 },
{ "minLength", 128 },
{ "requireUpper", true },
{ "requireLower", false },
{ "requireNumbers", true },
@@ -397,4 +397,48 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Put_MasterPasswordPolicy_ExcessiveMinLength_ReturnsBadRequest()
{
// Arrange
var policyType = PolicyType.MasterPassword;
var request = new PolicyRequestModel
{
Enabled = true,
Data = new Dictionary<string, object>
{
{ "minLength", 129 }
}
};
// Act
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}",
JsonContent.Create(request));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Put_MasterPasswordPolicy_ExcessiveMinComplexity_ReturnsBadRequest()
{
// Arrange
var policyType = PolicyType.MasterPassword;
var request = new PolicyRequestModel
{
Enabled = true,
Data = new Dictionary<string, object>
{
{ "minComplexity", 5 }
}
};
// Act
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}",
JsonContent.Create(request));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}

View File

@@ -264,4 +264,138 @@ public class MembersControllerTests : IClassFixture<ApiApplicationFactory>, IAsy
new Permissions { CreateNewCollections = true, ManageScim = true, ManageGroups = true, ManageUsers = true },
orgUser.GetPermissions());
}
[Fact]
public async Task Revoke_Member_Success()
{
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory, _organization.Id, OrganizationUserType.User);
var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var updatedUser = await _factory.GetService<IOrganizationUserRepository>()
.GetByIdAsync(orgUser.Id);
Assert.NotNull(updatedUser);
Assert.Equal(OrganizationUserStatusType.Revoked, updatedUser.Status);
}
[Fact]
public async Task Revoke_AlreadyRevoked_ReturnsBadRequest()
{
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory, _organization.Id, OrganizationUserType.User);
var revokeResponse = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode);
var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var error = await response.Content.ReadFromJsonAsync<ErrorResponseModel>();
Assert.Equal("Already revoked.", error?.Message);
}
[Fact]
public async Task Revoke_NotFound_ReturnsNotFound()
{
var response = await _client.PostAsync($"/public/members/{Guid.NewGuid()}/revoke", null);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Revoke_DifferentOrganization_ReturnsNotFound()
{
// Create a different organization
var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(ownerEmail);
var (otherOrganization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card);
// Create a user in the other organization
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory, otherOrganization.Id, OrganizationUserType.User);
// Re-authenticate with the original organization
await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id);
// Try to revoke the user from the other organization
var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Restore_Member_Success()
{
// Invite a user to revoke
var email = $"integration-test{Guid.NewGuid()}@example.com";
var inviteRequest = new MemberCreateRequestModel
{
Email = email,
Type = OrganizationUserType.User,
};
var inviteResponse = await _client.PostAsync("/public/members", JsonContent.Create(inviteRequest));
Assert.Equal(HttpStatusCode.OK, inviteResponse.StatusCode);
var invitedMember = await inviteResponse.Content.ReadFromJsonAsync<MemberResponseModel>();
Assert.NotNull(invitedMember);
// Revoke the invited user
var revokeResponse = await _client.PostAsync($"/public/members/{invitedMember.Id}/revoke", null);
Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode);
// Restore the user
var response = await _client.PostAsync($"/public/members/{invitedMember.Id}/restore", null);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Verify user is restored to Invited state
var updatedUser = await _factory.GetService<IOrganizationUserRepository>()
.GetByIdAsync(invitedMember.Id);
Assert.NotNull(updatedUser);
Assert.Equal(OrganizationUserStatusType.Invited, updatedUser.Status);
}
[Fact]
public async Task Restore_AlreadyActive_ReturnsBadRequest()
{
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory, _organization.Id, OrganizationUserType.User);
var response = await _client.PostAsync($"/public/members/{orgUser.Id}/restore", null);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var error = await response.Content.ReadFromJsonAsync<ErrorResponseModel>();
Assert.Equal("Already active.", error?.Message);
}
[Fact]
public async Task Restore_NotFound_ReturnsNotFound()
{
var response = await _client.PostAsync($"/public/members/{Guid.NewGuid()}/restore", null);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Restore_DifferentOrganization_ReturnsNotFound()
{
// Create a different organization
var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(ownerEmail);
var (otherOrganization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card);
// Create a user in the other organization
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory, otherOrganization.Id, OrganizationUserType.User);
// Re-authenticate with the original organization
await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id);
// Try to restore the user from the other organization
var response = await _client.PostAsync($"/public/members/{orgUser.Id}/restore", null);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}

View File

@@ -61,7 +61,8 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
Enabled = true,
Data = new Dictionary<string, object>
{
{ "minComplexity", 15},
{ "minComplexity", 4},
{ "minLength", 128 },
{ "requireLower", true}
}
};
@@ -78,7 +79,8 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
Assert.IsType<Guid>(result.Id);
Assert.NotEqual(default, result.Id);
Assert.NotNull(result.Data);
Assert.Equal(15, ((JsonElement)result.Data["minComplexity"]).GetInt32());
Assert.Equal(4, ((JsonElement)result.Data["minComplexity"]).GetInt32());
Assert.Equal(128, ((JsonElement)result.Data["minLength"]).GetInt32());
Assert.True(((JsonElement)result.Data["requireLower"]).GetBoolean());
// Assert against the database values
@@ -94,7 +96,7 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
Assert.NotNull(policy.Data);
var data = policy.GetDataModel<MasterPasswordPolicyData>();
var expectedData = new MasterPasswordPolicyData { MinComplexity = 15, RequireLower = true };
var expectedData = new MasterPasswordPolicyData { MinComplexity = 4, MinLength = 128, RequireLower = true };
AssertHelper.AssertPropertyEqual(expectedData, data);
}
@@ -242,4 +244,46 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Put_MasterPasswordPolicy_ExcessiveMinLength_ReturnsBadRequest()
{
// Arrange
var policyType = PolicyType.MasterPassword;
var request = new PolicyUpdateRequestModel
{
Enabled = true,
Data = new Dictionary<string, object>
{
{ "minLength", 129 }
}
};
// Act
var response = await _client.PutAsync($"/public/policies/{policyType}", JsonContent.Create(request));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Put_MasterPasswordPolicy_ExcessiveMinComplexity_ReturnsBadRequest()
{
// Arrange
var policyType = PolicyType.MasterPassword;
var request = new PolicyUpdateRequestModel
{
Enabled = true,
Data = new Dictionary<string, object>
{
{ "minComplexity", 5 }
}
};
// Act
var response = await _client.PutAsync($"/public/policies/{policyType}", JsonContent.Create(request));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}

View File

@@ -1,6 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<IsPackable>false</IsPackable>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>

View File

@@ -1,19 +1,28 @@
using System.Net;
using System.Text.Json;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.KeyManagement.Models.Requests;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Repositories;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Identity;
using NSubstitute;
using Xunit;
using static Bit.Core.KeyManagement.Enums.SignatureAlgorithm;
namespace Bit.Api.IntegrationTest.Controllers;
@@ -21,6 +30,8 @@ public class AccountsControllerTest : IClassFixture<ApiApplicationFactory>, IAsy
{
private static readonly string _masterKeyWrappedUserKey =
"2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=";
private static readonly string _mockEncryptedType7String = "7.AOs41Hd8OQiCPXjyJKCiDA==";
private static readonly string _mockEncryptedType7WrappedSigningKey = "7.DRv74Kg1RSlFSam1MNFlGD==";
private static readonly string _masterPasswordHash = "master_password_hash";
private static readonly string _newMasterPasswordHash = "new_master_password_hash";
@@ -35,6 +46,11 @@ public class AccountsControllerTest : IClassFixture<ApiApplicationFactory>, IAsy
private readonly IPushNotificationService _pushNotificationService;
private readonly IFeatureService _featureService;
private readonly IPasswordHasher<User> _passwordHasher;
private readonly IOrganizationRepository _organizationRepository;
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IUserSignatureKeyPairRepository _userSignatureKeyPairRepository;
private readonly IEventRepository _eventRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private string _ownerEmail = null!;
@@ -49,6 +65,11 @@ public class AccountsControllerTest : IClassFixture<ApiApplicationFactory>, IAsy
_pushNotificationService = _factory.GetService<IPushNotificationService>();
_featureService = _factory.GetService<IFeatureService>();
_passwordHasher = _factory.GetService<IPasswordHasher<User>>();
_organizationRepository = _factory.GetService<IOrganizationRepository>();
_ssoConfigRepository = _factory.GetService<ISsoConfigRepository>();
_userSignatureKeyPairRepository = _factory.GetService<IUserSignatureKeyPairRepository>();
_eventRepository = _factory.GetService<IEventRepository>();
_organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
}
public async Task InitializeAsync()
@@ -435,4 +456,531 @@ public class AccountsControllerTest : IClassFixture<ApiApplicationFactory>, IAsy
message.Content = JsonContent.Create(requestModel);
return await _client.SendAsync(message);
}
[Theory]
[BitAutoData]
public async Task PostSetPasswordAsync_V1_MasterPasswordDecryption_Success(string organizationSsoIdentifier)
{
// Arrange - Create organization and user
var ownerEmail = $"owner-{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(ownerEmail);
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
ownerEmail: ownerEmail,
name: "Test Org V1");
organization.UseSso = true;
organization.Identifier = organizationSsoIdentifier;
await _organizationRepository.ReplaceAsync(organization);
await _ssoConfigRepository.CreateAsync(new SsoConfig
{
OrganizationId = organization.Id,
Enabled = true,
Data = JsonSerializer.Serialize(new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.MasterPassword,
}, JsonHelpers.CamelCase),
});
// Create user with password initially, so we can login
var userEmail = $"user-{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(userEmail);
// Add user to organization
var user = await _userRepository.GetByEmailAsync(userEmail);
Assert.NotNull(user);
await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, userEmail,
OrganizationUserType.User, userStatusType: OrganizationUserStatusType.Invited);
// Login as the user
await _loginHelper.LoginAsync(userEmail);
// Remove the master password and keys to simulate newly registered SSO user
user.MasterPassword = null;
user.Key = null;
user.PrivateKey = null;
user.PublicKey = null;
await _userRepository.ReplaceAsync(user);
// V1 (Obsolete) request format - to be removed with PM-27327
var request = new
{
masterPasswordHash = _newMasterPasswordHash,
key = _masterKeyWrappedUserKey,
keys = new
{
publicKey = "v1-publicKey",
encryptedPrivateKey = "v1-encryptedPrivateKey"
},
kdf = 0, // PBKDF2_SHA256
kdfIterations = 600000,
kdfMemory = (int?)null,
kdfParallelism = (int?)null,
masterPasswordHint = "v1-integration-test-hint",
orgIdentifier = organization.Identifier
};
var jsonRequest = JsonSerializer.Serialize(request, JsonHelpers.CamelCase);
// Act
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password");
message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json");
var response = await _client.SendAsync(message);
// Assert
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
Assert.Fail($"Expected success but got {response.StatusCode}. Error: {errorContent}");
}
// Verify user in database
var updatedUser = await _userRepository.GetByEmailAsync(userEmail);
Assert.NotNull(updatedUser);
Assert.Equal("v1-integration-test-hint", updatedUser.MasterPasswordHint);
// Verify the master password is hashed and stored
Assert.NotNull(updatedUser.MasterPassword);
var verificationResult = _passwordHasher.VerifyHashedPassword(updatedUser, updatedUser.MasterPassword, _newMasterPasswordHash);
Assert.Equal(PasswordVerificationResult.Success, verificationResult);
// Verify KDF settings
Assert.Equal(KdfType.PBKDF2_SHA256, updatedUser.Kdf);
Assert.Equal(600_000, updatedUser.KdfIterations);
Assert.Null(updatedUser.KdfMemory);
Assert.Null(updatedUser.KdfParallelism);
// Verify timestamps are updated
Assert.Equal(DateTime.UtcNow, updatedUser.RevisionDate, TimeSpan.FromMinutes(1));
Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1));
// Verify keys are set (V1 uses Keys property)
Assert.Equal(_masterKeyWrappedUserKey, updatedUser.Key);
Assert.Equal("v1-publicKey", updatedUser.PublicKey);
Assert.Equal("v1-encryptedPrivateKey", updatedUser.PrivateKey);
// Verify User_ChangedPassword event was logged
var events = await _eventRepository.GetManyByUserAsync(updatedUser.Id, DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow.AddMinutes(1), new PageOptions { PageSize = 100 });
Assert.NotNull(events);
Assert.Contains(events.Data, e => e.Type == EventType.User_ChangedPassword && e.UserId == updatedUser.Id);
// Verify user was accepted into the organization
var orgUsers = await _organizationUserRepository.GetManyByUserAsync(updatedUser.Id);
var orgUser = orgUsers.FirstOrDefault(ou => ou.OrganizationId == organization.Id);
Assert.NotNull(orgUser);
Assert.Equal(OrganizationUserStatusType.Accepted, orgUser.Status);
}
[Theory]
[BitAutoData]
public async Task PostSetPasswordAsync_V2_MasterPasswordDecryption_Success(string organizationSsoIdentifier)
{
// Arrange - Create organization and user
var ownerEmail = $"owner-{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(ownerEmail);
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
ownerEmail: ownerEmail,
name: "Test Org");
organization.UseSso = true;
organization.Identifier = organizationSsoIdentifier;
await _organizationRepository.ReplaceAsync(organization);
await _ssoConfigRepository.CreateAsync(new SsoConfig
{
OrganizationId = organization.Id,
Enabled = true,
Data = JsonSerializer.Serialize(new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.MasterPassword,
}, JsonHelpers.CamelCase),
});
// Create user with password initially, so we can login
var userEmail = $"user-{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(userEmail);
// Add user to organization
var user = await _userRepository.GetByEmailAsync(userEmail);
Assert.NotNull(user);
await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, userEmail,
OrganizationUserType.User, userStatusType: OrganizationUserStatusType.Invited);
// Login as the user
await _loginHelper.LoginAsync(userEmail);
// Remove the master password and keys to simulate newly registered SSO user
user.MasterPassword = null;
user.Key = null;
user.PrivateKey = null;
user.PublicKey = null;
user.SignedPublicKey = null;
await _userRepository.ReplaceAsync(user);
var jsonRequest = CreateV2SetPasswordRequestJson(
userEmail,
organization.Identifier,
"integration-test-hint",
includeAccountKeys: true);
// Act
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password");
message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json");
var response = await _client.SendAsync(message);
// Assert
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
Assert.Fail($"Expected success but got {response.StatusCode}. Error: {errorContent}");
}
// Verify user in database
var updatedUser = await _userRepository.GetByEmailAsync(userEmail);
Assert.NotNull(updatedUser);
Assert.Equal("integration-test-hint", updatedUser.MasterPasswordHint);
// Verify the master password is hashed and stored
Assert.NotNull(updatedUser.MasterPassword);
var verificationResult = _passwordHasher.VerifyHashedPassword(updatedUser, updatedUser.MasterPassword, _newMasterPasswordHash);
Assert.Equal(PasswordVerificationResult.Success, verificationResult);
// Verify KDF settings
Assert.Equal(KdfType.PBKDF2_SHA256, updatedUser.Kdf);
Assert.Equal(600_000, updatedUser.KdfIterations);
Assert.Null(updatedUser.KdfMemory);
Assert.Null(updatedUser.KdfParallelism);
// Verify timestamps are updated
Assert.Equal(DateTime.UtcNow, updatedUser.RevisionDate, TimeSpan.FromMinutes(1));
Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1));
// Verify keys are set
Assert.Equal(_masterKeyWrappedUserKey, updatedUser.Key);
Assert.Equal("publicKey", updatedUser.PublicKey);
Assert.Equal(_mockEncryptedType7String, updatedUser.PrivateKey);
Assert.Equal("signedPublicKey", updatedUser.SignedPublicKey);
// Verify security state
Assert.Equal(2, updatedUser.SecurityVersion);
Assert.Equal("v2", updatedUser.SecurityState);
// Verify signature key pair data
var signatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(updatedUser.Id);
Assert.NotNull(signatureKeyPair);
Assert.Equal(Ed25519, signatureKeyPair.SignatureAlgorithm);
Assert.Equal(_mockEncryptedType7WrappedSigningKey, signatureKeyPair.WrappedSigningKey);
Assert.Equal("verifyingKey", signatureKeyPair.VerifyingKey);
// Verify User_ChangedPassword event was logged
var events = await _eventRepository.GetManyByUserAsync(updatedUser.Id, DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow.AddMinutes(1), new PageOptions { PageSize = 100 });
Assert.NotNull(events);
Assert.Contains(events.Data, e => e.Type == EventType.User_ChangedPassword && e.UserId == updatedUser.Id);
// Verify user was accepted into the organization
var orgUsers = await _organizationUserRepository.GetManyByUserAsync(updatedUser.Id);
var orgUser = orgUsers.FirstOrDefault(ou => ou.OrganizationId == organization.Id);
Assert.NotNull(orgUser);
Assert.Equal(OrganizationUserStatusType.Accepted, orgUser.Status);
}
[Theory]
[BitAutoData]
public async Task PostSetPasswordAsync_V2_TDEDecryption_Success(string organizationSsoIdentifier)
{
// Arrange - Create organization with TDE
var ownerEmail = $"owner-{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(ownerEmail);
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
ownerEmail: ownerEmail,
name: "Test Org TDE");
organization.UseSso = true;
organization.Identifier = organizationSsoIdentifier;
await _organizationRepository.ReplaceAsync(organization);
// Configure SSO for TDE (TrustedDeviceEncryption)
await _ssoConfigRepository.CreateAsync(new SsoConfig
{
OrganizationId = organization.Id,
Enabled = true,
Data = JsonSerializer.Serialize(new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption,
}, JsonHelpers.CamelCase),
});
// Create user with password initially, so we can login
var userEmail = $"user-{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(userEmail);
var user = await _userRepository.GetByEmailAsync(userEmail);
Assert.NotNull(user);
// Add user to organization and confirm them (TDE users are confirmed, not invited)
await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, userEmail,
OrganizationUserType.User, userStatusType: OrganizationUserStatusType.Confirmed);
// Login as the user
await _loginHelper.LoginAsync(userEmail);
// Set up TDE user with V2 account keys but no master password
// TDE users already have their account keys from device provisioning
user.MasterPassword = null;
user.Key = null;
user.PublicKey = "tde-publicKey";
user.PrivateKey = _mockEncryptedType7String;
user.SignedPublicKey = "tde-signedPublicKey";
user.SecurityVersion = 2;
user.SecurityState = "v2-tde";
await _userRepository.ReplaceAsync(user);
// Create signature key pair for TDE user
var signatureKeyPairData = new Core.KeyManagement.Models.Data.SignatureKeyPairData(
Ed25519,
_mockEncryptedType7WrappedSigningKey,
"tde-verifyingKey");
var setSignatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(user.Id);
if (setSignatureKeyPair == null)
{
var newKeyPair = new Core.KeyManagement.Entities.UserSignatureKeyPair
{
UserId = user.Id,
SignatureAlgorithm = signatureKeyPairData.SignatureAlgorithm,
SigningKey = signatureKeyPairData.WrappedSigningKey,
VerifyingKey = signatureKeyPairData.VerifyingKey,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow
};
newKeyPair.SetNewId();
await _userSignatureKeyPairRepository.CreateAsync(newKeyPair);
}
var jsonRequest = CreateV2SetPasswordRequestJson(
userEmail,
organization.Identifier,
"tde-test-hint",
includeAccountKeys: false);
// Act
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password");
message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json");
var response = await _client.SendAsync(message);
// Assert
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
Assert.Fail($"Expected success but got {response.StatusCode}. Error: {errorContent}");
}
// Verify user in database
var updatedUser = await _userRepository.GetByEmailAsync(userEmail);
Assert.NotNull(updatedUser);
Assert.Equal("tde-test-hint", updatedUser.MasterPasswordHint);
// Verify the master password is hashed and stored
Assert.NotNull(updatedUser.MasterPassword);
var verificationResult = _passwordHasher.VerifyHashedPassword(updatedUser, updatedUser.MasterPassword, _newMasterPasswordHash);
Assert.Equal(PasswordVerificationResult.Success, verificationResult);
// Verify KDF settings
Assert.Equal(KdfType.PBKDF2_SHA256, updatedUser.Kdf);
Assert.Equal(600_000, updatedUser.KdfIterations);
Assert.Null(updatedUser.KdfMemory);
Assert.Null(updatedUser.KdfParallelism);
// Verify timestamps are updated
Assert.Equal(DateTime.UtcNow, updatedUser.RevisionDate, TimeSpan.FromMinutes(1));
Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1));
// Verify key is set
Assert.Equal(_masterKeyWrappedUserKey, updatedUser.Key);
// Verify AccountKeys are preserved (TDE users already had V2 keys)
Assert.Equal("tde-publicKey", updatedUser.PublicKey);
Assert.Equal(_mockEncryptedType7String, updatedUser.PrivateKey);
Assert.Equal("tde-signedPublicKey", updatedUser.SignedPublicKey);
Assert.Equal(2, updatedUser.SecurityVersion);
Assert.Equal("v2-tde", updatedUser.SecurityState);
// Verify signature key pair is preserved (TDE users already had signature keys)
var signatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(updatedUser.Id);
Assert.NotNull(signatureKeyPair);
Assert.Equal(Ed25519, signatureKeyPair.SignatureAlgorithm);
Assert.Equal(_mockEncryptedType7WrappedSigningKey, signatureKeyPair.WrappedSigningKey);
Assert.Equal("tde-verifyingKey", signatureKeyPair.VerifyingKey);
// Verify User_ChangedPassword event was logged
var events = await _eventRepository.GetManyByUserAsync(updatedUser.Id, DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow.AddMinutes(1), new PageOptions { PageSize = 100 });
Assert.NotNull(events);
Assert.Contains(events.Data, e => e.Type == EventType.User_ChangedPassword && e.UserId == updatedUser.Id);
// Verify user remains confirmed in the organization
var orgUsers = await _organizationUserRepository.GetManyByUserAsync(updatedUser.Id);
var orgUser = orgUsers.FirstOrDefault(ou => ou.OrganizationId == organization.Id);
Assert.NotNull(orgUser);
Assert.Equal(OrganizationUserStatusType.Confirmed, orgUser.Status);
}
[Fact]
public async Task PostSetPasswordAsync_V2_Unauthorized_ReturnsUnauthorized()
{
// Arrange - Don't login
var jsonRequest = CreateV2SetPasswordRequestJson(
"test@bitwarden.com",
"test-org-identifier",
"test-hint",
includeAccountKeys: true);
// Act
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password");
message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json");
var response = await _client.SendAsync(message);
// Assert
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task PostSetPasswordAsync_V2_MismatchedKdfSettings_ReturnsBadRequest()
{
// Arrange
var email = $"kdf-mismatch-test-{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(email);
await _loginHelper.LoginAsync(email);
// Test mismatched KDF settings (600000 vs 650000 iterations)
var request = new
{
masterPasswordAuthentication = new
{
kdf = new
{
kdfType = 0,
iterations = 600000
},
masterPasswordAuthenticationHash = _newMasterPasswordHash,
salt = email
},
masterPasswordUnlock = new
{
kdf = new
{
kdfType = 0,
iterations = 650000 // Different from authentication KDF
},
masterKeyWrappedUserKey = _masterKeyWrappedUserKey,
salt = email
},
accountKeys = new
{
userKeyEncryptedAccountPrivateKey = "7.AOs41Hd8OQiCPXjyJKCiDA==",
accountPublicKey = "public-key"
},
orgIdentifier = "test-org-identifier"
};
var jsonRequest = JsonSerializer.Serialize(request, JsonHelpers.CamelCase);
// Act
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password");
message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json");
var response = await _client.SendAsync(message);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Theory]
[InlineData(KdfType.PBKDF2_SHA256, 1, null, null)]
[InlineData(KdfType.Argon2id, 4, null, 5)]
[InlineData(KdfType.Argon2id, 4, 65, null)]
public async Task PostSetPasswordAsync_V2_InvalidKdfSettings_ReturnsBadRequest(
KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism)
{
// Arrange
var email = $"invalid-kdf-test-{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(email);
await _loginHelper.LoginAsync(email);
var jsonRequest = CreateV2SetPasswordRequestJson(
email,
"test-org-identifier",
"test-hint",
includeAccountKeys: true,
kdfType: kdf,
kdfIterations: kdfIterations,
kdfMemory: kdfMemory,
kdfParallelism: kdfParallelism);
// Act
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password");
message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json");
var response = await _client.SendAsync(message);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private static string CreateV2SetPasswordRequestJson(
string userEmail,
string orgIdentifier,
string hint,
bool includeAccountKeys = true,
KdfType? kdfType = null,
int? kdfIterations = null,
int? kdfMemory = null,
int? kdfParallelism = null)
{
var kdf = new
{
kdfType = (int)(kdfType ?? KdfType.PBKDF2_SHA256),
iterations = kdfIterations ?? 600000,
memory = kdfMemory,
parallelism = kdfParallelism
};
var request = new
{
masterPasswordAuthentication = new
{
kdf,
masterPasswordAuthenticationHash = _newMasterPasswordHash,
salt = userEmail
},
masterPasswordUnlock = new
{
kdf,
masterKeyWrappedUserKey = _masterKeyWrappedUserKey,
salt = userEmail
},
accountKeys = includeAccountKeys ? new
{
accountPublicKey = "publicKey",
userKeyEncryptedAccountPrivateKey = _mockEncryptedType7String,
publicKeyEncryptionKeyPair = new
{
publicKey = "publicKey",
wrappedPrivateKey = _mockEncryptedType7String,
signedPublicKey = "signedPublicKey"
},
signatureKeyPair = new
{
signatureAlgorithm = "ed25519",
wrappedSigningKey = _mockEncryptedType7WrappedSigningKey,
verifyingKey = "verifyingKey"
},
securityState = new
{
securityVersion = 2,
securityState = "v2"
}
} : null,
masterPasswordHint = hint,
orgIdentifier
};
return JsonSerializer.Serialize(request, JsonHelpers.CamelCase);
}
}

View File

@@ -6,6 +6,7 @@ using Bit.Api.Models.Public.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
@@ -114,4 +115,64 @@ public class CollectionsControllerTests : IClassFixture<ApiApplicationFactory>,
Assert.NotEmpty(result.Item2.Groups);
Assert.NotEmpty(result.Item2.Users);
}
[Fact]
public async Task List_ExcludesDefaultUserCollections_IncludesGroupsAndUsers()
{
// Arrange
var collectionRepository = _factory.GetService<ICollectionRepository>();
var groupRepository = _factory.GetService<IGroupRepository>();
var defaultCollection = new Collection
{
OrganizationId = _organization.Id,
Name = "My Items",
Type = CollectionType.DefaultUserCollection
};
await collectionRepository.CreateAsync(defaultCollection, null, null);
var group = await groupRepository.CreateAsync(new Group
{
OrganizationId = _organization.Id,
Name = "Test Group",
ExternalId = $"test-group-{Guid.NewGuid()}",
});
var (_, user) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory,
_organization.Id,
OrganizationUserType.User);
var sharedCollection = await OrganizationTestHelpers.CreateCollectionAsync(
_factory,
_organization.Id,
"Shared Collection with Access",
externalId: "shared-collection-with-access",
groups:
[
new CollectionAccessSelection { Id = group.Id, ReadOnly = false, HidePasswords = false, Manage = true }
],
users:
[
new CollectionAccessSelection { Id = user.Id, ReadOnly = true, HidePasswords = true, Manage = false }
]);
// Act
var response = await _client.GetFromJsonAsync<ListResponseModel<CollectionResponseModel>>("public/collections");
// Assert
Assert.NotNull(response);
Assert.DoesNotContain(response.Data, c => c.Id == defaultCollection.Id);
var collectionResponse = response.Data.First(c => c.Id == sharedCollection.Id);
Assert.NotNull(collectionResponse.Groups);
Assert.Single(collectionResponse.Groups);
var groupResponse = collectionResponse.Groups.First();
Assert.Equal(group.Id, groupResponse.Id);
Assert.False(groupResponse.ReadOnly);
Assert.False(groupResponse.HidePasswords);
Assert.True(groupResponse.Manage);
}
}