From 2bd74b3caa994e080e7d0aec58b520113b4ab604 Mon Sep 17 00:00:00 2001 From: Hinton Date: Thu, 15 May 2025 17:37:57 +0200 Subject: [PATCH] Add support for running integration tests using sqlserver --- ...nizationUsersControllerPerformanceTests.cs | 4 +- .../Factories/ApiApplicationFactory.cs | 23 ++-- .../SqlServerApiApplicationFactory.cs | 7 ++ .../Factories/WebApplicationFactoryBase.cs | 119 +++++++++--------- test/IntegrationTestCommon/ITestDatabase.cs | 19 +++ .../IntegrationTestCommon.csproj | 1 + .../SqlServerTestDatabase.cs | 70 +++++++++++ .../SqliteTestDatabase.cs | 41 ++++++ 8 files changed, 206 insertions(+), 78 deletions(-) create mode 100644 test/Api.IntegrationTest/Factories/SqlServerApiApplicationFactory.cs create mode 100644 test/IntegrationTestCommon/ITestDatabase.cs create mode 100644 test/IntegrationTestCommon/SqlServerTestDatabase.cs create mode 100644 test/IntegrationTestCommon/SqliteTestDatabase.cs diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs index 94432b05a0..65f85b5ac9 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs @@ -9,12 +9,12 @@ namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; public class OrganizationUsersControllerPerformanceTest(ITestOutputHelper testOutputHelper) { - [Theory(Skip = "Performance test")] + [Theory] [InlineData(100)] [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..c3e565269f 100644 --- a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs +++ b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs @@ -1,10 +1,10 @@ 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; #nullable enable @@ -12,16 +12,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 SqlServerTestDatabase()) { - SqliteConnection = new SqliteConnection(_connectionString); - SqliteConnection.Open(); + } + + public ApiApplicationFactory(ITestDatabase db) + { + TestDatabase = db; + _handleDbDisposal = true; _identityApplicationFactory = new IdentityApplicationFactory(); - _identityApplicationFactory.SqliteConnection = SqliteConnection; + _identityApplicationFactory.TestDatabase = TestDatabase; } protected override void ConfigureWebHost(IWebHostBuilder builder) @@ -73,12 +76,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/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index 76fa0f03d1..a3b3e182e6 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,12 +36,12 @@ 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; } private readonly List> _configureTestServices = new(); private readonly List> _configureAppConfiguration = new(); - private bool _handleSqliteDisposal { get; set; } + private bool _handleDbDisposal { get; set; } public void SubstituteService(Action mockService) @@ -119,13 +118,48 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory /// protected override void ConfigureWebHost(IWebHostBuilder builder) { - if (SqliteConnection == null) + if (TestDatabase == null) { - SqliteConnection = new SqliteConnection("DataSource=:memory:"); - SqliteConnection.Open(); - _handleSqliteDisposal = true; + TestDatabase = new SqliteTestDatabase(); + _handleDbDisposal = true; } + var config = 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" }, + }; + + // Some database drivers modify the connection string + TestDatabase.ModifyGlobalSettings(config); + builder.ConfigureAppConfiguration(c => { c.SetBasePath(AppContext.BaseDirectory) @@ -134,39 +168,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 +179,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 (_handleDbDisposal) + { + 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 +287,14 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory protected override void Dispose(bool disposing) { base.Dispose(disposing); - if (_handleSqliteDisposal) + if (_handleDbDisposal) { - SqliteConnection!.Dispose(); - } - } + _handleDbDisposal = false; - 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(); + if (TestDatabase != null) + { + TestDatabase!.Dispose(); + } } - 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..4a4dc456d0 --- /dev/null +++ b/test/IntegrationTestCommon/SqlServerTestDatabase.cs @@ -0,0 +1,70 @@ +using Bit.Core.Settings; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Migrator; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Bit.IntegrationTestCommon; + +public class SqlServerTestDatabase : ITestDatabase +{ + public string SqlServerConnection { get; set; } + + public SqlServerTestDatabase() + { + SqlServerConnection = "Server=localhost;Database=vault_test;User Id=SA;Password=SET_A_PASSWORD_HERE_123;Encrypt=True;TrustServerCertificate=True;"; + } + + 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(); + } +}