1
0
mirror of https://github.com/bitwarden/server.git synced 2025-03-02 04:11:04 +01:00

Merge branch 'main' into km/userkey-rotation-v2

This commit is contained in:
Bernd Schoolmann 2025-01-31 19:49:57 +01:00 committed by GitHub
commit 0716858212
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 327 additions and 79 deletions

View File

@ -314,7 +314,7 @@ jobs:
output-format: sarif
- name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with:
sarif_file: ${{ steps.container-scan.outputs.sarif }}

View File

@ -46,7 +46,7 @@ jobs:
--output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with:
sarif_file: cx_result.sarif

View File

@ -125,6 +125,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Notifications.Test", "test\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Dapper.Test", "test\Infrastructure.Dapper.Test\Infrastructure.Dapper.Test.csproj", "{4A725DB3-BE4F-4C23-9087-82D0610D67AF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events.IntegrationTest", "test\Events.IntegrationTest\Events.IntegrationTest.csproj", "{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -313,6 +315,10 @@ Global
{4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Release|Any CPU.Build.0 = Release|Any CPU
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -363,6 +369,7 @@ Global
{81673EFB-7134-4B4B-A32F-1EA05F0EF3CE} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@ -421,6 +421,11 @@ public class OrganizationsController : Controller
private void UpdateOrganization(Organization organization, OrganizationEditModel model)
{
if (_accessControlService.UserHasPermission(Permission.Org_Name_Edit))
{
organization.Name = WebUtility.HtmlEncode(model.Name);
}
if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox))
{
organization.Enabled = model.Enabled;

View File

@ -12,6 +12,7 @@
var canViewBilling = AccessControlService.UserHasPermission(Permission.Org_Billing_View);
var canViewPlan = AccessControlService.UserHasPermission(Permission.Org_Plan_View);
var canViewLicensing = AccessControlService.UserHasPermission(Permission.Org_Licensing_View);
var canEditName = AccessControlService.UserHasPermission(Permission.Org_Name_Edit);
var canCheckEnabled = AccessControlService.UserHasPermission(Permission.Org_CheckEnabledBox);
var canEditPlan = AccessControlService.UserHasPermission(Permission.Org_Plan_Edit);
var canEditLicensing = AccessControlService.UserHasPermission(Permission.Org_Licensing_Edit);
@ -28,7 +29,7 @@
<div class="col-sm">
<div class="mb-3">
<label class="form-label" asp-for="Name"></label>
<input type="text" class="form-control" asp-for="Name" value="@Model.Name" required>
<input type="text" class="form-control" asp-for="Name" value="@Model.Name" required disabled="@(canEditName ? null : "disabled")">
</div>
</div>
</div>

View File

@ -22,6 +22,7 @@ public enum Permission
Org_List_View,
Org_OrgInformation_View,
Org_GeneralDetails_View,
Org_Name_Edit,
Org_CheckEnabledBox,
Org_BusinessInformation_View,
Org_InitiateTrial,

View File

@ -24,6 +24,7 @@ public static class RolePermissionMapping
Permission.User_Billing_Edit,
Permission.User_Billing_LaunchGateway,
Permission.User_NewDeviceException_Edit,
Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox,
Permission.Org_List_View,
Permission.Org_OrgInformation_View,
@ -71,6 +72,7 @@ public static class RolePermissionMapping
Permission.User_Billing_Edit,
Permission.User_Billing_LaunchGateway,
Permission.User_NewDeviceException_Edit,
Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox,
Permission.Org_List_View,
Permission.Org_OrgInformation_View,
@ -116,6 +118,7 @@ public static class RolePermissionMapping
Permission.User_Billing_View,
Permission.User_Billing_LaunchGateway,
Permission.User_NewDeviceException_Edit,
Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox,
Permission.Org_List_View,
Permission.Org_OrgInformation_View,
@ -148,6 +151,7 @@ public static class RolePermissionMapping
Permission.User_Billing_View,
Permission.User_Billing_Edit,
Permission.User_Billing_LaunchGateway,
Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox,
Permission.Org_List_View,
Permission.Org_OrgInformation_View,
@ -185,6 +189,7 @@ public static class RolePermissionMapping
Permission.User_Premium_View,
Permission.User_Licensing_View,
Permission.User_Licensing_Edit,
Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox,
Permission.Org_List_View,
Permission.Org_OrgInformation_View,

View File

@ -107,6 +107,7 @@ public static class FeatureFlagKeys
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
public const string IntegrationPage = "pm-14505-admin-console-integration-page";
public const string DeviceApprovalRequestAdminNotifications = "pm-15637-device-approval-request-admin-notifications";
public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection";

View File

@ -85,28 +85,17 @@ public class DeviceValidator(
}
}
// At this point we have established either new device verification is not required or the NewDeviceOtp is valid
// At this point we have established either new device verification is not required or the NewDeviceOtp is valid,
// so we save the device to the database and proceed with authentication
requestDevice.UserId = context.User.Id;
await _deviceService.SaveAsync(requestDevice);
context.Device = requestDevice;
// backwards compatibility -- If NewDeviceVerification not enabled send the new login emails
// PM-13340: removal Task; remove entire if block emails should no longer be sent
if (!_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification))
{
// This ensures the user doesn't receive a "new device" email on the first login
var now = DateTime.UtcNow;
if (now - context.User.CreationDate > TimeSpan.FromMinutes(10))
{
var deviceType = requestDevice.Type.GetType().GetMember(requestDevice.Type.ToString())
.FirstOrDefault()?.GetCustomAttribute<DisplayAttribute>()?.GetName();
if (!_globalSettings.DisableEmailNewDevice)
{
await _mailService.SendNewDeviceLoggedInEmail(context.User.Email, deviceType, now,
_currentContext.IpAddress);
}
}
await SendNewDeviceLoginEmail(context.User, requestDevice);
}
return true;
}
@ -174,6 +163,19 @@ public class DeviceValidator(
return DeviceValidationResultType.NewDeviceVerificationRequired;
}
private async Task SendNewDeviceLoginEmail(User user, Device requestDevice)
{
// Ensure that the user doesn't receive a "new device" email on the first login
var now = DateTime.UtcNow;
if (now - user.CreationDate > TimeSpan.FromMinutes(10))
{
var deviceType = requestDevice.Type.GetType().GetMember(requestDevice.Type.ToString())
.FirstOrDefault()?.GetCustomAttribute<DisplayAttribute>()?.GetName();
await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now,
_currentContext.IpAddress);
}
}
public async Task<Device> GetKnownDeviceAsync(User user, Device device)
{
if (user == null || device == null)

View File

@ -46,27 +46,17 @@ public class OrganizationDomainRepository : Repository<Core.Entities.Organizatio
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var domains = await dbContext.OrganizationDomains
.Where(x => x.VerifiedDate == null
&& x.JobRunCount != 3
&& x.NextRunDate.Year == date.Year
&& x.NextRunDate.Month == date.Month
&& x.NextRunDate.Day == date.Day
&& x.NextRunDate.Hour == date.Hour)
.AsNoTracking()
.ToListAsync();
var start36HoursWindow = date.AddHours(-36);
var end36HoursWindow = date;
//Get records that have ignored/failed by the background service
var pastDomains = dbContext.OrganizationDomains
.AsEnumerable()
.Where(x => (date - x.NextRunDate).TotalHours > 36
var pastDomains = await dbContext.OrganizationDomains
.Where(x => x.NextRunDate >= start36HoursWindow
&& x.NextRunDate <= end36HoursWindow
&& x.VerifiedDate == null
&& x.JobRunCount != 3)
.ToList();
.ToListAsync();
var results = domains.Union(pastDomains);
return Mapper.Map<List<Core.Entities.OrganizationDomain>>(results);
return Mapper.Map<List<Core.Entities.OrganizationDomain>>(pastDomains);
}
public async Task<OrganizationDomainSsoDetailsData?> GetOrganizationDomainSsoDetailsAsync(string email)

View File

@ -0,0 +1,29 @@
using System.Net.Http.Json;
using Bit.Core.Enums;
using Bit.Events.Models;
namespace Bit.Events.IntegrationTest.Controllers;
public class CollectControllerTests
{
// This is a very simple test, and should be updated to assert more things, but for now
// it ensures that the events startup doesn't throw any errors with fairly basic configuration.
[Fact]
public async Task Post_Works()
{
var eventsApplicationFactory = new EventsApplicationFactory();
var (accessToken, _) = await eventsApplicationFactory.LoginWithNewAccount();
var client = eventsApplicationFactory.CreateAuthedClient(accessToken);
var response = await client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.User_ClientExportedVault,
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
}

View File

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Events\Events.csproj" />
<ProjectReference Include="..\IntegrationTestCommon\IntegrationTestCommon.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,57 @@
using Bit.Identity.Models.Request.Accounts;
using Bit.IntegrationTestCommon.Factories;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Events.IntegrationTest;
public class EventsApplicationFactory : WebApplicationFactoryBase<Startup>
{
private readonly IdentityApplicationFactory _identityApplicationFactory;
private const string _connectionString = "DataSource=:memory:";
public EventsApplicationFactory()
{
SqliteConnection = new SqliteConnection(_connectionString);
SqliteConnection.Open();
_identityApplicationFactory = new IdentityApplicationFactory();
_identityApplicationFactory.SqliteConnection = SqliteConnection;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureTestServices(services =>
{
services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.BackchannelHttpHandler = _identityApplicationFactory.Server.CreateHandler();
});
});
}
/// <summary>
/// Helper for registering and logging in to a new account
/// </summary>
public async Task<(string Token, string RefreshToken)> LoginWithNewAccount(string email = "integration-test@bitwarden.com", string masterPasswordHash = "master_password_hash")
{
await _identityApplicationFactory.RegisterAsync(new RegisterRequestModel
{
Email = email,
MasterPasswordHash = masterPasswordHash,
});
return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash);
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
SqliteConnection!.Dispose();
}
}

View File

@ -0,0 +1 @@
global using Xunit;

View File

@ -227,7 +227,7 @@ public class DeviceValidatorTests
}
[Theory, BitAutoData]
public async void ValidateRequestDeviceAsync_NewDeviceVerificationFeatureFlagFalse_SendsEmail_ReturnsTrue(
public async void ValidateRequestDeviceAsync_ExistingUserNewDeviceLogin_SendNewDeviceLoginEmail_ReturnsTrue(
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
@ -237,8 +237,6 @@ public class DeviceValidatorTests
_globalSettings.DisableEmailNewDevice = false;
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
.Returns(null as Device);
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
.Returns(false);
// set user creation to more than 10 minutes ago
context.User.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(11);
@ -253,7 +251,7 @@ public class DeviceValidatorTests
}
[Theory, BitAutoData]
public async void ValidateRequestDeviceAsync_NewDeviceVerificationFeatureFlagFalse_NewUser_DoesNotSendEmail_ReturnsTrue(
public async void ValidateRequestDeviceAsync_NewUserNewDeviceLogin_DoesNotSendNewDeviceLoginEmail_ReturnsTrue(
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
@ -263,8 +261,6 @@ public class DeviceValidatorTests
_globalSettings.DisableEmailNewDevice = false;
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
.Returns(null as Device);
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
.Returns(false);
// set user creation to less than 10 minutes ago
context.User.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(9);
@ -279,7 +275,7 @@ public class DeviceValidatorTests
}
[Theory, BitAutoData]
public async void ValidateRequestDeviceAsync_NewDeviceVerificationFeatureFlagFalse_DisableEmailTrue_DoesNotSendEmail_ReturnsTrue(
public async void ValidateRequestDeviceAsynce_DisableNewDeviceLoginEmailTrue_DoesNotSendNewDeviceEmail_ReturnsTrue(
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
@ -289,8 +285,6 @@ public class DeviceValidatorTests
_globalSettings.DisableEmailNewDevice = true;
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
.Returns(null as Device);
_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
.Returns(false);
// Act
var result = await _sut.ValidateRequestDeviceAsync(request, context);

View File

@ -188,4 +188,122 @@ public class OrganizationDomainRepositoryTests
var expectedDomain2 = domains.FirstOrDefault(domain => domain.DomainName == organizationDomain2.DomainName);
Assert.Null(expectedDomain2);
}
[DatabaseTheory, DatabaseData]
public async Task GetManyByNextRunDateAsync_ShouldReturnUnverifiedDomains(
IOrganizationRepository organizationRepository,
IOrganizationDomainRepository organizationDomainRepository)
{
// Arrange
var id = Guid.NewGuid();
var organization1 = await organizationRepository.CreateAsync(new Organization
{
Name = $"Test Org {id}",
BillingEmail = $"test+{id}@example.com",
Plan = "Test",
PrivateKey = "privatekey",
});
var organizationDomain = new OrganizationDomain
{
OrganizationId = organization1.Id,
DomainName = $"domain2+{id}@example.com",
Txt = "btw+12345"
};
var within36HoursWindow = 1;
organizationDomain.SetNextRunDate(within36HoursWindow);
await organizationDomainRepository.CreateAsync(organizationDomain);
var date = organizationDomain.NextRunDate;
// Act
var domains = await organizationDomainRepository.GetManyByNextRunDateAsync(date);
// Assert
var expectedDomain = domains.FirstOrDefault(domain => domain.DomainName == organizationDomain.DomainName);
Assert.NotNull(expectedDomain);
}
[DatabaseTheory, DatabaseData]
public async Task GetManyByNextRunDateAsync_ShouldNotReturnUnverifiedDomains_WhenNextRunDateIsOutside36hoursWindow(
IOrganizationRepository organizationRepository,
IOrganizationDomainRepository organizationDomainRepository)
{
// Arrange
var id = Guid.NewGuid();
var organization1 = await organizationRepository.CreateAsync(new Organization
{
Name = $"Test Org {id}",
BillingEmail = $"test+{id}@example.com",
Plan = "Test",
PrivateKey = "privatekey",
});
var organizationDomain = new OrganizationDomain
{
OrganizationId = organization1.Id,
DomainName = $"domain2+{id}@example.com",
Txt = "btw+12345"
};
var outside36HoursWindow = 20;
organizationDomain.SetNextRunDate(outside36HoursWindow);
await organizationDomainRepository.CreateAsync(organizationDomain);
var date = DateTimeOffset.UtcNow.Date.AddDays(1);
// Act
var domains = await organizationDomainRepository.GetManyByNextRunDateAsync(date);
// Assert
var expectedDomain = domains.FirstOrDefault(domain => domain.DomainName == organizationDomain.DomainName);
Assert.Null(expectedDomain);
}
[DatabaseTheory, DatabaseData]
public async Task GetManyByNextRunDateAsync_ShouldNotReturnVerifiedDomains(
IOrganizationRepository organizationRepository,
IOrganizationDomainRepository organizationDomainRepository)
{
// Arrange
var id = Guid.NewGuid();
var organization1 = await organizationRepository.CreateAsync(new Organization
{
Name = $"Test Org {id}",
BillingEmail = $"test+{id}@example.com",
Plan = "Test",
PrivateKey = "privatekey",
});
var organizationDomain = new OrganizationDomain
{
OrganizationId = organization1.Id,
DomainName = $"domain2+{id}@example.com",
Txt = "btw+12345"
};
var within36HoursWindow = 1;
organizationDomain.SetNextRunDate(within36HoursWindow);
organizationDomain.SetVerifiedDate();
await organizationDomainRepository.CreateAsync(organizationDomain);
var date = DateTimeOffset.UtcNow.Date.AddDays(1);
// Act
var domains = await organizationDomainRepository.GetManyByNextRunDateAsync(date);
// Assert
var expectedDomain = domains.FirstOrDefault(domain => domain.DomainName == organizationDomain.DomainName);
Assert.Null(expectedDomain);
}
}

View File

@ -14,6 +14,7 @@ using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
@ -188,44 +189,27 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
// QUESTION: The normal licensing service should run fine on developer machines but not in CI
// should we have a fork here to leave the normal service for developers?
// TODO: Eventually add the license file to CI
var licensingService = services.First(sd => sd.ServiceType == typeof(ILicensingService));
services.Remove(licensingService);
services.AddSingleton<ILicensingService, NoopLicensingService>();
Replace<ILicensingService, NoopLicensingService>(services);
// FUTURE CONSIDERATION: Add way to run this self hosted/cloud, for now it is cloud only
var pushRegistrationService = services.First(sd => sd.ServiceType == typeof(IPushRegistrationService));
services.Remove(pushRegistrationService);
services.AddSingleton<IPushRegistrationService, NoopPushRegistrationService>();
Replace<IPushRegistrationService, NoopPushRegistrationService>(services);
// Even though we are cloud we currently set this up as cloud, we can use the EF/selfhosted service
// instead of using Noop for this service
// TODO: Install and use azurite in CI pipeline
var eventWriteService = services.First(sd => sd.ServiceType == typeof(IEventWriteService));
services.Remove(eventWriteService);
services.AddSingleton<IEventWriteService, RepositoryEventWriteService>();
Replace<IEventWriteService, RepositoryEventWriteService>(services);
var eventRepositoryService = services.First(sd => sd.ServiceType == typeof(IEventRepository));
services.Remove(eventRepositoryService);
services.AddSingleton<IEventRepository, EventRepository>();
Replace<IEventRepository, EventRepository>(services);
var mailDeliveryService = services.First(sd => sd.ServiceType == typeof(IMailDeliveryService));
services.Remove(mailDeliveryService);
services.AddSingleton<IMailDeliveryService, NoopMailDeliveryService>();
Replace<IMailDeliveryService, NoopMailDeliveryService>(services);
var captchaValidationService = services.First(sd => sd.ServiceType == typeof(ICaptchaValidationService));
services.Remove(captchaValidationService);
services.AddSingleton<ICaptchaValidationService, NoopCaptchaValidationService>();
Replace<ICaptchaValidationService, NoopCaptchaValidationService>(services);
// TODO: Install and use azurite in CI pipeline
var installationDeviceRepository =
services.First(sd => sd.ServiceType == typeof(IInstallationDeviceRepository));
services.Remove(installationDeviceRepository);
services.AddSingleton<IInstallationDeviceRepository, NoopRepos.InstallationDeviceRepository>();
Replace<IInstallationDeviceRepository, NoopRepos.InstallationDeviceRepository>(services);
// TODO: Install and use azurite in CI pipeline
var referenceEventService = services.First(sd => sd.ServiceType == typeof(IReferenceEventService));
services.Remove(referenceEventService);
services.AddSingleton<IReferenceEventService, NoopReferenceEventService>();
Replace<IReferenceEventService, NoopReferenceEventService>(services);
// Our Rate limiter works so well that it begins to fail tests unless we carve out
// one whitelisted ip. We should still test the rate limiter though and they should change the Ip
@ -245,14 +229,9 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
// Noop StripePaymentService - this could be changed to integrate with our Stripe test account
var stripePaymentService = services.First(sd => sd.ServiceType == typeof(IPaymentService));
services.Remove(stripePaymentService);
services.AddSingleton(Substitute.For<IPaymentService>());
Replace(services, Substitute.For<IPaymentService>());
var organizationBillingService =
services.First(sd => sd.ServiceType == typeof(IOrganizationBillingService));
services.Remove(organizationBillingService);
services.AddSingleton(Substitute.For<IOrganizationBillingService>());
Replace(services, Substitute.For<IOrganizationBillingService>());
});
foreach (var configureTestService in _configureTestServices)
@ -261,6 +240,35 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
}
}
private static void Replace<TService, TNewImplementation>(IServiceCollection services)
where TService : class
where TNewImplementation : class, TService
{
services.RemoveAll<TService>();
services.AddSingleton<TService, TNewImplementation>();
}
private static void Replace<TService>(IServiceCollection services, TService implementation)
where TService : class
{
services.RemoveAll<TService>();
services.AddSingleton<TService>(implementation);
}
public HttpClient CreateAuthedClient(string accessToken)
{
var handler = Server.CreateHandler((context) =>
{
context.Request.Headers.Authorization = $"Bearer {accessToken}";
});
return new HttpClient(handler)
{
BaseAddress = Server.BaseAddress,
Timeout = TimeSpan.FromSeconds(200),
};
}
public DatabaseContext GetDatabaseContext()
{
var scope = Services.CreateScope();