You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
585 lines
20 KiB
585 lines
20 KiB
using System.Net; |
|
using System.Text.Json; |
|
using Bit.Api.AdminConsole.Models.Request; |
|
using Bit.Api.AdminConsole.Models.Response.Organizations; |
|
using Bit.Api.IntegrationTest.Factories; |
|
using Bit.Api.IntegrationTest.Helpers; |
|
using Bit.Core.AdminConsole.Entities; |
|
using Bit.Core.AdminConsole.Enums; |
|
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; |
|
using Bit.Core.AdminConsole.Repositories; |
|
using Bit.Core.Billing.Enums; |
|
using Bit.Core.Entities; |
|
using Bit.Core.Enums; |
|
using Bit.Core.Repositories; |
|
using Bit.Test.Common.Helpers; |
|
using NSubstitute; |
|
using Xunit; |
|
|
|
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; |
|
|
|
public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime |
|
{ |
|
private readonly HttpClient _client; |
|
private readonly ApiApplicationFactory _factory; |
|
private readonly LoginHelper _loginHelper; |
|
|
|
private Organization _organization = null!; |
|
private string _ownerEmail = null!; |
|
|
|
public PoliciesControllerTests(ApiApplicationFactory factory) |
|
{ |
|
_factory = factory; |
|
_factory.SubstituteService<Core.Services.IFeatureService>(featureService => |
|
{ |
|
featureService |
|
.IsEnabled("pm-19467-create-default-location") |
|
.Returns(true); |
|
}); |
|
_client = factory.CreateClient(); |
|
_loginHelper = new LoginHelper(_factory, _client); |
|
} |
|
|
|
public async Task InitializeAsync() |
|
{ |
|
_ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; |
|
await _factory.LoginWithNewAccount(_ownerEmail); |
|
|
|
(_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, |
|
ownerEmail: _ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); |
|
|
|
await _loginHelper.LoginAsync(_ownerEmail); |
|
} |
|
|
|
public Task DisposeAsync() |
|
{ |
|
_client.Dispose(); |
|
return Task.CompletedTask; |
|
} |
|
|
|
[Fact] |
|
public async Task PutVNext_OrganizationDataOwnershipPolicy_Success() |
|
{ |
|
// Arrange |
|
const PolicyType policyType = PolicyType.OrganizationDataOwnership; |
|
|
|
const string defaultCollectionName = "Test Default Collection"; |
|
var request = new SavePolicyRequest |
|
{ |
|
Policy = new PolicyRequestModel |
|
{ |
|
Enabled = true, |
|
}, |
|
Metadata = new Dictionary<string, object> |
|
{ |
|
{ "defaultUserCollectionName", defaultCollectionName } |
|
} |
|
}; |
|
|
|
var (_, admin) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, |
|
_organization.Id, OrganizationUserType.Admin); |
|
|
|
var (_, user) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, |
|
_organization.Id, OrganizationUserType.User); |
|
|
|
// Act |
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext", |
|
JsonContent.Create(request)); |
|
|
|
// Assert |
|
await AssertResponse(); |
|
|
|
await AssertPolicy(); |
|
|
|
await AssertDefaultCollectionCreatedOnlyForUserTypeAsync(); |
|
return; |
|
|
|
async Task AssertDefaultCollectionCreatedOnlyForUserTypeAsync() |
|
{ |
|
var collectionRepository = _factory.GetService<ICollectionRepository>(); |
|
await AssertUserExpectations(collectionRepository); |
|
await AssertAdminExpectations(collectionRepository); |
|
} |
|
|
|
async Task AssertUserExpectations(ICollectionRepository collectionRepository) |
|
{ |
|
var collections = await collectionRepository.GetManyByUserIdAsync(user.UserId!.Value); |
|
var defaultCollection = collections.FirstOrDefault(c => c.Name == defaultCollectionName); |
|
Assert.NotNull(defaultCollection); |
|
Assert.Equal(_organization.Id, defaultCollection.OrganizationId); |
|
} |
|
|
|
async Task AssertAdminExpectations(ICollectionRepository collectionRepository) |
|
{ |
|
var collections = await collectionRepository.GetManyByUserIdAsync(admin.UserId!.Value); |
|
var defaultCollection = collections.FirstOrDefault(c => c.Name == defaultCollectionName); |
|
Assert.Null(defaultCollection); |
|
} |
|
|
|
async Task AssertResponse() |
|
{ |
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode); |
|
var content = await response.Content.ReadFromJsonAsync<PolicyResponseModel>(); |
|
|
|
Assert.True(content.Enabled); |
|
Assert.Equal(policyType, content.Type); |
|
Assert.Equal(_organization.Id, content.OrganizationId); |
|
} |
|
|
|
async Task AssertPolicy() |
|
{ |
|
var policyRepository = _factory.GetService<IPolicyRepository>(); |
|
var policy = await policyRepository.GetByOrganizationIdTypeAsync(_organization.Id, policyType); |
|
|
|
Assert.NotNull(policy); |
|
Assert.True(policy.Enabled); |
|
Assert.Equal(policyType, policy.Type); |
|
Assert.Null(policy.Data); |
|
Assert.Equal(_organization.Id, policy.OrganizationId); |
|
} |
|
} |
|
|
|
[Fact] |
|
public async Task PutVNext_MasterPasswordPolicy_Success() |
|
{ |
|
// Arrange |
|
var policyType = PolicyType.MasterPassword; |
|
var request = new SavePolicyRequest |
|
{ |
|
Policy = new PolicyRequestModel |
|
{ |
|
Enabled = true, |
|
Data = new Dictionary<string, object> |
|
{ |
|
{ "minComplexity", 4 }, |
|
{ "minLength", 128 }, |
|
{ "requireUpper", true }, |
|
{ "requireLower", false }, |
|
{ "requireNumbers", true }, |
|
{ "requireSpecial", false }, |
|
{ "enforceOnLogin", true } |
|
} |
|
} |
|
}; |
|
|
|
// Act |
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext", |
|
JsonContent.Create(request)); |
|
|
|
// Assert |
|
await AssertResponse(); |
|
|
|
await AssertPolicyDataForMasterPasswordPolicy(); |
|
return; |
|
|
|
async Task AssertPolicyDataForMasterPasswordPolicy() |
|
{ |
|
var policyRepository = _factory.GetService<IPolicyRepository>(); |
|
var policy = await policyRepository.GetByOrganizationIdTypeAsync(_organization.Id, policyType); |
|
|
|
AssertPolicy(policy); |
|
AssertMasterPasswordPolicyData(policy); |
|
} |
|
|
|
async Task AssertResponse() |
|
{ |
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode); |
|
var content = await response.Content.ReadFromJsonAsync<PolicyResponseModel>(); |
|
|
|
Assert.True(content.Enabled); |
|
Assert.Equal(policyType, content.Type); |
|
Assert.Equal(_organization.Id, content.OrganizationId); |
|
} |
|
|
|
void AssertPolicy(Policy policy) |
|
{ |
|
Assert.NotNull(policy); |
|
Assert.True(policy.Enabled); |
|
Assert.Equal(policyType, policy.Type); |
|
Assert.Equal(_organization.Id, policy.OrganizationId); |
|
Assert.NotNull(policy.Data); |
|
} |
|
|
|
void AssertMasterPasswordPolicyData(Policy policy) |
|
{ |
|
var resultData = policy.GetDataModel<MasterPasswordPolicyData>(); |
|
|
|
var json = JsonSerializer.Serialize(request.Policy.Data); |
|
var expectedData = JsonSerializer.Deserialize<MasterPasswordPolicyData>(json); |
|
AssertHelper.AssertPropertyEqual(resultData, expectedData); |
|
} |
|
} |
|
|
|
[Fact] |
|
public async Task Put_MasterPasswordPolicy_InvalidDataType_ReturnsBadRequest() |
|
{ |
|
// Arrange |
|
var policyType = PolicyType.MasterPassword; |
|
var request = new PolicyRequestModel |
|
{ |
|
Enabled = true, |
|
Data = new Dictionary<string, object> |
|
{ |
|
{ "minLength", "not a number" }, // Wrong type - should be int |
|
{ "requireUpper", true } |
|
} |
|
}; |
|
|
|
// Act |
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}", |
|
JsonContent.Create(request)); |
|
|
|
// Assert |
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); |
|
var content = await response.Content.ReadAsStringAsync(); |
|
Assert.Contains("minLength", content); // Verify field name is in error message |
|
} |
|
|
|
[Fact] |
|
public async Task Put_SendOptionsPolicy_InvalidDataType_ReturnsBadRequest() |
|
{ |
|
// Arrange |
|
var policyType = PolicyType.SendOptions; |
|
var request = new PolicyRequestModel |
|
{ |
|
Enabled = true, |
|
Data = new Dictionary<string, object> |
|
{ |
|
{ "disableHideEmail", "not a boolean" } // Wrong type - should be bool |
|
} |
|
}; |
|
|
|
// 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_ResetPasswordPolicy_InvalidDataType_ReturnsBadRequest() |
|
{ |
|
// Arrange |
|
var policyType = PolicyType.ResetPassword; |
|
var request = new PolicyRequestModel |
|
{ |
|
Enabled = true, |
|
Data = new Dictionary<string, object> |
|
{ |
|
{ "autoEnrollEnabled", 123 } // Wrong type - should be bool |
|
} |
|
}; |
|
|
|
// 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 PutVNext_MasterPasswordPolicy_InvalidDataType_ReturnsBadRequest() |
|
{ |
|
// Arrange |
|
var policyType = PolicyType.MasterPassword; |
|
var request = new SavePolicyRequest |
|
{ |
|
Policy = new PolicyRequestModel |
|
{ |
|
Enabled = true, |
|
Data = new Dictionary<string, object> |
|
{ |
|
{ "minComplexity", "not a number" }, // Wrong type - should be int |
|
{ "minLength", 12 } |
|
} |
|
} |
|
}; |
|
|
|
// Act |
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext", |
|
JsonContent.Create(request)); |
|
|
|
// Assert |
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); |
|
var content = await response.Content.ReadAsStringAsync(); |
|
Assert.Contains("minComplexity", content); // Verify field name is in error message |
|
} |
|
|
|
[Fact] |
|
public async Task PutVNext_SendOptionsPolicy_InvalidDataType_ReturnsBadRequest() |
|
{ |
|
// Arrange |
|
var policyType = PolicyType.SendOptions; |
|
var request = new SavePolicyRequest |
|
{ |
|
Policy = new PolicyRequestModel |
|
{ |
|
Enabled = true, |
|
Data = new Dictionary<string, object> |
|
{ |
|
{ "disableHideEmail", "not a boolean" } // Wrong type - should be bool |
|
} |
|
} |
|
}; |
|
|
|
// Act |
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext", |
|
JsonContent.Create(request)); |
|
|
|
// Assert |
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); |
|
} |
|
|
|
[Fact] |
|
public async Task PutVNext_ResetPasswordPolicy_InvalidDataType_ReturnsBadRequest() |
|
{ |
|
// Arrange |
|
var policyType = PolicyType.ResetPassword; |
|
var request = new SavePolicyRequest |
|
{ |
|
Policy = new PolicyRequestModel |
|
{ |
|
Enabled = true, |
|
Data = new Dictionary<string, object> |
|
{ |
|
{ "autoEnrollEnabled", 123 } // Wrong type - should be bool |
|
} |
|
} |
|
}; |
|
|
|
// Act |
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext", |
|
JsonContent.Create(request)); |
|
|
|
// Assert |
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); |
|
} |
|
|
|
[Fact] |
|
public async Task Put_PolicyWithNullData_Success() |
|
{ |
|
// Arrange |
|
var policyType = PolicyType.SingleOrg; |
|
var request = new PolicyRequestModel |
|
{ |
|
Enabled = true, |
|
Data = null |
|
}; |
|
|
|
// Act |
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}", |
|
JsonContent.Create(request)); |
|
|
|
// Assert |
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode); |
|
} |
|
|
|
[Fact] |
|
public async Task PutVNext_PolicyWithNullData_Success() |
|
{ |
|
// Arrange |
|
var policyType = PolicyType.TwoFactorAuthentication; |
|
var request = new SavePolicyRequest |
|
{ |
|
Policy = new PolicyRequestModel |
|
{ |
|
Enabled = true, |
|
Data = null |
|
}, |
|
Metadata = null |
|
}; |
|
|
|
// Act |
|
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext", |
|
JsonContent.Create(request)); |
|
|
|
// 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); |
|
} |
|
|
|
[Fact] |
|
public async Task GetMasterPasswordPolicy_Unauthenticated_ReturnsUnauthorized() |
|
{ |
|
// Arrange |
|
_client.DefaultRequestHeaders.Authorization = null; |
|
|
|
// Act |
|
var response = await _client.GetAsync($"/organizations/{_organization.Id}/policies/master-password"); |
|
|
|
// Assert |
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); |
|
} |
|
|
|
[Fact] |
|
public async Task GetMasterPasswordPolicy_AuthenticatedNonMember_ReturnsForbidden() |
|
{ |
|
// Arrange |
|
var nonMemberEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; |
|
await _factory.LoginWithNewAccount(nonMemberEmail); |
|
await _loginHelper.LoginAsync(nonMemberEmail); |
|
|
|
// Act |
|
var response = await _client.GetAsync($"/organizations/{_organization.Id}/policies/master-password"); |
|
|
|
// Assert |
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); |
|
} |
|
|
|
[Fact] |
|
public async Task GetMasterPasswordPolicy_AuthenticatedMember_ReturnsPolicy() |
|
{ |
|
// Arrange - owner is already logged in from InitializeAsync |
|
var policyRepository = _factory.GetService<IPolicyRepository>(); |
|
await policyRepository.CreateAsync(new Policy |
|
{ |
|
OrganizationId = _organization.Id, |
|
Type = PolicyType.MasterPassword, |
|
Enabled = true |
|
}); |
|
|
|
// Act |
|
var response = await _client.GetAsync($"/organizations/{_organization.Id}/policies/master-password"); |
|
|
|
// Assert |
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode); |
|
var content = await response.Content.ReadFromJsonAsync<PolicyResponseModel>(); |
|
Assert.NotNull(content); |
|
Assert.True(content.Enabled); |
|
Assert.Equal(PolicyType.MasterPassword, content.Type); |
|
Assert.Equal(_organization.Id, content.OrganizationId); |
|
} |
|
|
|
/// <summary> |
|
/// An OrganizationUser with Invited status can still have a UserId linked due to the SSO JIT provisioning bug |
|
/// (PM-34092). This requirement exists to support that case, so Invited + UserId must succeed. |
|
/// </summary> |
|
[Fact] |
|
public async Task GetMasterPasswordPolicy_InvitedMemberWithLinkedUserId_ReturnsPolicy() |
|
{ |
|
// Arrange |
|
var policyRepository = _factory.GetService<IPolicyRepository>(); |
|
await policyRepository.CreateAsync(new Policy |
|
{ |
|
OrganizationId = _organization.Id, |
|
Type = PolicyType.MasterPassword, |
|
Enabled = true |
|
}); |
|
|
|
// Create a user account and add them to the org in Invited status (but with UserId populated) |
|
var invitedEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; |
|
await _factory.LoginWithNewAccount(invitedEmail); |
|
|
|
var userRepository = _factory.GetService<IUserRepository>(); |
|
var user = await userRepository.GetByEmailAsync(invitedEmail); |
|
|
|
var organizationUserRepository = _factory.GetService<IOrganizationUserRepository>(); |
|
await organizationUserRepository.CreateAsync(new OrganizationUser |
|
{ |
|
OrganizationId = _organization.Id, |
|
UserId = user!.Id, |
|
Type = OrganizationUserType.User, |
|
Status = OrganizationUserStatusType.Invited |
|
}); |
|
|
|
await _loginHelper.LoginAsync(invitedEmail); |
|
|
|
// Act |
|
var response = await _client.GetAsync($"/organizations/{_organization.Id}/policies/master-password"); |
|
|
|
// Assert |
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode); |
|
var content = await response.Content.ReadFromJsonAsync<PolicyResponseModel>(); |
|
Assert.NotNull(content); |
|
Assert.True(content.Enabled); |
|
Assert.Equal(PolicyType.MasterPassword, content.Type); |
|
} |
|
|
|
[Fact] |
|
public async Task Put_SingleOrgPolicy_RevokesNonCompliantUser() |
|
{ |
|
// Arrange |
|
// Create a second organization (Org B) with its own owner |
|
var orgBOwnerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; |
|
await _factory.LoginWithNewAccount(orgBOwnerEmail); |
|
var (orgB, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, |
|
ownerEmail: orgBOwnerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); |
|
|
|
// Create a user that belongs to both Org A and Org B |
|
var multiOrgUserEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; |
|
await _factory.LoginWithNewAccount(multiOrgUserEmail); |
|
|
|
var orgUserInOrgA = await OrganizationTestHelpers.CreateUserAsync(_factory, _organization.Id, |
|
multiOrgUserEmail, OrganizationUserType.User); |
|
await OrganizationTestHelpers.CreateUserAsync(_factory, orgB.Id, |
|
multiOrgUserEmail, OrganizationUserType.User); |
|
|
|
// Re-authenticate as the owner of Org A |
|
await _loginHelper.LoginAsync(_ownerEmail); |
|
|
|
var request = new PolicyRequestModel |
|
{ |
|
Enabled = true, |
|
Data = null |
|
}; |
|
|
|
// Act - Enable Single Org policy on Org A |
|
var response = await _client.PutAsync( |
|
$"/organizations/{_organization.Id}/policies/{PolicyType.SingleOrg}", |
|
JsonContent.Create(request)); |
|
|
|
// Assert |
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode); |
|
|
|
// Verify the multi-org user was revoked in Org A |
|
var organizationUserRepository = _factory.GetService<IOrganizationUserRepository>(); |
|
var updatedOrgUser = await organizationUserRepository.GetByIdAsync(orgUserInOrgA.Id); |
|
Assert.NotNull(updatedOrgUser); |
|
Assert.Equal(OrganizationUserStatusType.Revoked, updatedOrgUser.Status); |
|
} |
|
}
|
|
|