diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs index 94432b05a0..d77a41f52e 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs @@ -14,7 +14,7 @@ public class OrganizationUsersControllerPerformanceTest(ITestOutputHelper testOu [InlineData(60000)] public async Task GetAsync(int seats) { - await using var factory = new ApiApplicationFactory(); + await using var factory = new SqlServerApiApplicationFactory(); var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); diff --git a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs index a0963745de..08c5973936 100644 --- a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs +++ b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs @@ -1,10 +1,11 @@ using Bit.Core; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Enums; +using Bit.IntegrationTestCommon; using Bit.IntegrationTestCommon.Factories; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.TestHost; -using Microsoft.Data.Sqlite; +using Xunit; #nullable enable @@ -12,16 +13,19 @@ namespace Bit.Api.IntegrationTest.Factories; public class ApiApplicationFactory : WebApplicationFactoryBase { - private readonly IdentityApplicationFactory _identityApplicationFactory; - private const string _connectionString = "DataSource=:memory:"; + protected IdentityApplicationFactory _identityApplicationFactory; - public ApiApplicationFactory() + public ApiApplicationFactory() : this(new SqliteTestDatabase()) { - SqliteConnection = new SqliteConnection(_connectionString); - SqliteConnection.Open(); + } + + protected ApiApplicationFactory(ITestDatabase db) + { + TestDatabase = db; _identityApplicationFactory = new IdentityApplicationFactory(); - _identityApplicationFactory.SqliteConnection = SqliteConnection; + _identityApplicationFactory.TestDatabase = TestDatabase; + _identityApplicationFactory.ManagesDatabase = false; } protected override void ConfigureWebHost(IWebHostBuilder builder) @@ -47,6 +51,10 @@ public class ApiApplicationFactory : WebApplicationFactoryBase public async Task<(string Token, string RefreshToken)> LoginWithNewAccount( string email = "integration-test@bitwarden.com", string masterPasswordHash = "master_password_hash") { + // This might be the first action in a test and since it forwards to the Identity server, we need to ensure that + // this server is initialized since it's responsible for seeding the database. + Assert.NotNull(Services); + await _identityApplicationFactory.RegisterNewIdentityFactoryUserAsync( new RegisterFinishRequestModel { @@ -73,12 +81,6 @@ public class ApiApplicationFactory : WebApplicationFactoryBase return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash); } - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - SqliteConnection!.Dispose(); - } - /// /// Helper for logging in via client secret. /// Currently used for Secrets Manager service accounts diff --git a/test/Api.IntegrationTest/Factories/SqlServerApiApplicationFactory.cs b/test/Api.IntegrationTest/Factories/SqlServerApiApplicationFactory.cs new file mode 100644 index 0000000000..b8cf721232 --- /dev/null +++ b/test/Api.IntegrationTest/Factories/SqlServerApiApplicationFactory.cs @@ -0,0 +1,7 @@ +using Bit.IntegrationTestCommon; + +#nullable enable + +namespace Bit.Api.IntegrationTest.Factories; + +public class SqlServerApiApplicationFactory() : ApiApplicationFactory(new SqlServerTestDatabase()); diff --git a/test/Events.IntegrationTest/EventsApplicationFactory.cs b/test/Events.IntegrationTest/EventsApplicationFactory.cs index b1c3ef8bf5..7d692c442a 100644 --- a/test/Events.IntegrationTest/EventsApplicationFactory.cs +++ b/test/Events.IntegrationTest/EventsApplicationFactory.cs @@ -1,11 +1,11 @@ using Bit.Core; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Enums; +using Bit.IntegrationTestCommon; 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; @@ -13,15 +13,18 @@ namespace Bit.Events.IntegrationTest; public class EventsApplicationFactory : WebApplicationFactoryBase { private readonly IdentityApplicationFactory _identityApplicationFactory; - private const string _connectionString = "DataSource=:memory:"; - public EventsApplicationFactory() + public EventsApplicationFactory() : this(new SqliteTestDatabase()) { - SqliteConnection = new SqliteConnection(_connectionString); - SqliteConnection.Open(); + } + + protected EventsApplicationFactory(ITestDatabase db) + { + TestDatabase = db; _identityApplicationFactory = new IdentityApplicationFactory(); - _identityApplicationFactory.SqliteConnection = SqliteConnection; + _identityApplicationFactory.TestDatabase = TestDatabase; + _identityApplicationFactory.ManagesDatabase = false; } protected override void ConfigureWebHost(IWebHostBuilder builder) @@ -42,6 +45,10 @@ public class EventsApplicationFactory : WebApplicationFactoryBase /// public async Task<(string Token, string RefreshToken)> LoginWithNewAccount(string email = "integration-test@bitwarden.com", string masterPasswordHash = "master_password_hash") { + // This might be the first action in a test and since it forwards to the Identity server, we need to ensure that + // this server is initialized since it's responsible for seeding the database. + Assert.NotNull(Services); + await _identityApplicationFactory.RegisterNewIdentityFactoryUserAsync( new RegisterFinishRequestModel { @@ -59,10 +66,4 @@ public class EventsApplicationFactory : WebApplicationFactoryBase return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash); } - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - SqliteConnection!.Dispose(); - } } diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index 76fa0f03d1..6bf0b5edce 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -9,7 +9,6 @@ using Bit.Infrastructure.EntityFramework.Repositories; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -37,14 +36,19 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory /// /// This will need to be set BEFORE using the Server property /// - public SqliteConnection? SqliteConnection { get; set; } + public ITestDatabase TestDatabase { get; set; } = new SqliteTestDatabase(); + + /// + /// If set to true the factory will manage the database lifecycle, including migrations. + /// + /// + /// This will need to be set BEFORE using the Server property + /// + public bool ManagesDatabase { get; set; } = true; private readonly List> _configureTestServices = new(); private readonly List> _configureAppConfiguration = new(); - private bool _handleSqliteDisposal { get; set; } - - public void SubstituteService(Action mockService) where TService : class { @@ -119,12 +123,41 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory /// protected override void ConfigureWebHost(IWebHostBuilder builder) { - if (SqliteConnection == null) + var config = new Dictionary { - SqliteConnection = new SqliteConnection("DataSource=:memory:"); - SqliteConnection.Open(); - _handleSqliteDisposal = true; - } + // Manually insert a EF provider so that ConfigureServices will add EF repositories but we will override + // DbContextOptions to use an in memory database + { "globalSettings:databaseProvider", "postgres" }, + { "globalSettings:postgreSql:connectionString", "Host=localhost;Username=test;Password=test;Database=test" }, + + // Clear the redis connection string for distributed caching, forcing an in-memory implementation + { "globalSettings:redis:connectionString", "" }, + + // Clear Storage + { "globalSettings:attachment:connectionString", null }, + { "globalSettings:events:connectionString", null }, + { "globalSettings:send:connectionString", null }, + { "globalSettings:notifications:connectionString", null }, + { "globalSettings:storage:connectionString", null }, + + // This will force it to use an ephemeral key for IdentityServer + { "globalSettings:developmentDirectory", null }, + + // Email Verification + { "globalSettings:enableEmailVerification", "true" }, + { "globalSettings:disableUserRegistration", "false" }, + { "globalSettings:launchDarkly:flagValues:email-verification", "true" }, + + // New Device Verification + { "globalSettings:disableEmailNewDevice", "false" }, + + // Web push notifications + { "globalSettings:webPush:vapidPublicKey", "BGBtAM0bU3b5jsB14IjBYarvJZ6rWHilASLudTTYDDBi7a-3kebo24Yus_xYeOMZ863flAXhFAbkL6GVSrxgErg" }, + { "globalSettings:launchDarkly:flagValues:web-push", "true" }, + }; + + // Some database drivers modify the connection string + TestDatabase.ModifyGlobalSettings(config); builder.ConfigureAppConfiguration(c => { @@ -134,39 +167,7 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory c.AddUserSecrets(typeof(Identity.Startup).Assembly, optional: true); - c.AddInMemoryCollection(new Dictionary - { - // Manually insert a EF provider so that ConfigureServices will add EF repositories but we will override - // DbContextOptions to use an in memory database - { "globalSettings:databaseProvider", "postgres" }, - { "globalSettings:postgreSql:connectionString", "Host=localhost;Username=test;Password=test;Database=test" }, - - // Clear the redis connection string for distributed caching, forcing an in-memory implementation - { "globalSettings:redis:connectionString", ""}, - - // Clear Storage - { "globalSettings:attachment:connectionString", null}, - { "globalSettings:events:connectionString", null}, - { "globalSettings:send:connectionString", null}, - { "globalSettings:notifications:connectionString", null}, - { "globalSettings:storage:connectionString", null}, - - // This will force it to use an ephemeral key for IdentityServer - { "globalSettings:developmentDirectory", null }, - - - // Email Verification - { "globalSettings:enableEmailVerification", "true" }, - { "globalSettings:disableUserRegistration", "false" }, - { "globalSettings:launchDarkly:flagValues:email-verification", "true" }, - - // New Device Verification - { "globalSettings:disableEmailNewDevice", "false" }, - - // Web push notifications - { "globalSettings:webPush:vapidPublicKey", "BGBtAM0bU3b5jsB14IjBYarvJZ6rWHilASLudTTYDDBi7a-3kebo24Yus_xYeOMZ863flAXhFAbkL6GVSrxgErg" }, - { "globalSettings:launchDarkly:flagValues:web-push", "true" }, - }); + c.AddInMemoryCollection(config); }); // Run configured actions after defaults to allow them to take precedence @@ -177,17 +178,16 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory builder.ConfigureTestServices(services => { - var dbContextOptions = services.First(sd => sd.ServiceType == typeof(DbContextOptions)); + var dbContextOptions = + services.First(sd => sd.ServiceType == typeof(DbContextOptions)); services.Remove(dbContextOptions); - services.AddScoped(services => - { - return new DbContextOptionsBuilder() - .UseSqlite(SqliteConnection) - .UseApplicationServiceProvider(services) - .Options; - }); - MigrateDbContext(services); + // Add database to the service collection + TestDatabase.AddDatabase(services); + if (ManagesDatabase) + { + TestDatabase.Migrate(services); + } // 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? @@ -286,22 +286,11 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory protected override void Dispose(bool disposing) { base.Dispose(disposing); - if (_handleSqliteDisposal) + if (ManagesDatabase) { - SqliteConnection!.Dispose(); + // Avoid calling Dispose twice + ManagesDatabase = false; + TestDatabase.Dispose(); } } - - private void MigrateDbContext(IServiceCollection serviceCollection) where TContext : DbContext - { - var serviceProvider = serviceCollection.BuildServiceProvider(); - using var scope = serviceProvider.CreateScope(); - var services = scope.ServiceProvider; - var context = services.GetRequiredService(); - if (_handleSqliteDisposal) - { - context.Database.EnsureDeleted(); - } - context.Database.EnsureCreated(); - } } diff --git a/test/IntegrationTestCommon/ITestDatabase.cs b/test/IntegrationTestCommon/ITestDatabase.cs new file mode 100644 index 0000000000..c6d51e428f --- /dev/null +++ b/test/IntegrationTestCommon/ITestDatabase.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.IntegrationTestCommon; + +#nullable enable + +public interface ITestDatabase +{ + public void AddDatabase(IServiceCollection serviceCollection); + + public void Migrate(IServiceCollection serviceCollection); + + public void Dispose(); + + public void ModifyGlobalSettings(Dictionary config) + { + // Default implementation does nothing + } +} diff --git a/test/IntegrationTestCommon/IntegrationTestCommon.csproj b/test/IntegrationTestCommon/IntegrationTestCommon.csproj index 3e8e55524b..a20a14f222 100644 --- a/test/IntegrationTestCommon/IntegrationTestCommon.csproj +++ b/test/IntegrationTestCommon/IntegrationTestCommon.csproj @@ -11,6 +11,7 @@ + diff --git a/test/IntegrationTestCommon/SqlServerTestDatabase.cs b/test/IntegrationTestCommon/SqlServerTestDatabase.cs new file mode 100644 index 0000000000..93ffa90d3d --- /dev/null +++ b/test/IntegrationTestCommon/SqlServerTestDatabase.cs @@ -0,0 +1,83 @@ +using Bit.Core.Settings; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Migrator; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Bit.IntegrationTestCommon; + +public class SqlServerTestDatabase : ITestDatabase +{ + private string _sqlServerConnection { get; set; } + + public SqlServerTestDatabase() + { + // Grab the connection string from the Identity project user secrets + var identityBuilder = new ConfigurationBuilder(); + identityBuilder.AddUserSecrets(typeof(Identity.Startup).Assembly, optional: true); + var identityConfig = identityBuilder.Build(); + var identityConnectionString = identityConfig.GetSection("globalSettings:sqlServer:connectionString").Value; + + // Replace the database name in the connection string to use a test database + var testConnectionString = new SqlConnectionStringBuilder(identityConnectionString) + { + InitialCatalog = "vault_test" + }.ConnectionString; + + _sqlServerConnection = testConnectionString; + } + + public void ModifyGlobalSettings(Dictionary config) + { + config["globalSettings:databaseProvider"] = "sqlserver"; + config["globalSettings:sqlServer:connectionString"] = _sqlServerConnection; + } + + public void AddDatabase(IServiceCollection serviceCollection) + { + serviceCollection.AddScoped(s => new DbContextOptionsBuilder() + .UseSqlServer(_sqlServerConnection) + .UseApplicationServiceProvider(s) + .Options); + } + + public void Migrate(IServiceCollection serviceCollection) + { + var serviceProvider = serviceCollection.BuildServiceProvider(); + using var scope = serviceProvider.CreateScope(); + var services = scope.ServiceProvider; + var globalSettings = services.GetRequiredService(); + var logger = services.GetRequiredService>(); + + var migrator = new SqlServerDbMigrator(globalSettings, logger); + migrator.MigrateDatabase(); + } + + public void Dispose() + { + var masterConnectionString = new SqlConnectionStringBuilder(_sqlServerConnection) + { + InitialCatalog = "master" + }.ConnectionString; + + using var connection = new SqlConnection(masterConnectionString); + var databaseName = new SqlConnectionStringBuilder(_sqlServerConnection).InitialCatalog; + + connection.Open(); + + var databaseNameQuoted = new SqlCommandBuilder().QuoteIdentifier(databaseName); + + using (var cmd = new SqlCommand($"ALTER DATABASE {databaseNameQuoted} SET single_user WITH rollback IMMEDIATE", connection)) + { + cmd.ExecuteNonQuery(); + } + + using (var cmd = new SqlCommand($"DROP DATABASE {databaseNameQuoted}", connection)) + { + cmd.ExecuteNonQuery(); + } + } +} diff --git a/test/IntegrationTestCommon/SqliteTestDatabase.cs b/test/IntegrationTestCommon/SqliteTestDatabase.cs new file mode 100644 index 0000000000..7d1ec2f07e --- /dev/null +++ b/test/IntegrationTestCommon/SqliteTestDatabase.cs @@ -0,0 +1,41 @@ +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.IntegrationTestCommon; + +public class SqliteTestDatabase : ITestDatabase +{ + private SqliteConnection SqliteConnection { get; set; } + + public SqliteTestDatabase() + { + SqliteConnection = new SqliteConnection("DataSource=:memory:"); + SqliteConnection.Open(); + } + + public void AddDatabase(IServiceCollection serviceCollection) + { + serviceCollection.AddScoped(s => new DbContextOptionsBuilder() + .UseSqlite(SqliteConnection) + .UseApplicationServiceProvider(s) + .Options); + } + + public void Migrate(IServiceCollection serviceCollection) + { + var serviceProvider = serviceCollection.BuildServiceProvider(); + using var scope = serviceProvider.CreateScope(); + var services = scope.ServiceProvider; + var context = services.GetRequiredService(); + + context.Database.EnsureDeleted(); + context.Database.EnsureCreated(); + } + + public void Dispose() + { + SqliteConnection.Dispose(); + } +}