1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-07 11:40:31 -05:00

[PM-21079] Add support to integration tests for using sqlserver (#5823)

Adds a SqlServerApiApplicationFactory which allows you to run api tests using SqlServer. Currently a new database is create and destroyed for each test. In the future we'd like a more optimized way to do this.

The database logic is abstracted away in a ITestDatabase interface which handles the configuration, migration and teardown.
This commit is contained in:
Oscar Hinton 2025-06-02 11:06:16 +02:00 committed by GitHub
parent 20105b85aa
commit d7d90e7f3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 236 additions and 93 deletions

View File

@ -14,7 +14,7 @@ public class OrganizationUsersControllerPerformanceTest(ITestOutputHelper testOu
[InlineData(60000)] [InlineData(60000)]
public async Task GetAsync(int seats) public async Task GetAsync(int seats)
{ {
await using var factory = new ApiApplicationFactory(); await using var factory = new SqlServerApiApplicationFactory();
var client = factory.CreateClient(); var client = factory.CreateClient();
var db = factory.GetDatabaseContext(); var db = factory.GetDatabaseContext();

View File

@ -1,10 +1,11 @@
using Bit.Core; using Bit.Core;
using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.IntegrationTestCommon;
using Bit.IntegrationTestCommon.Factories; using Bit.IntegrationTestCommon.Factories;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.TestHost;
using Microsoft.Data.Sqlite; using Xunit;
#nullable enable #nullable enable
@ -12,16 +13,19 @@ namespace Bit.Api.IntegrationTest.Factories;
public class ApiApplicationFactory : WebApplicationFactoryBase<Startup> public class ApiApplicationFactory : WebApplicationFactoryBase<Startup>
{ {
private readonly IdentityApplicationFactory _identityApplicationFactory; protected IdentityApplicationFactory _identityApplicationFactory;
private const string _connectionString = "DataSource=:memory:";
public ApiApplicationFactory() public ApiApplicationFactory() : this(new SqliteTestDatabase())
{ {
SqliteConnection = new SqliteConnection(_connectionString); }
SqliteConnection.Open();
protected ApiApplicationFactory(ITestDatabase db)
{
TestDatabase = db;
_identityApplicationFactory = new IdentityApplicationFactory(); _identityApplicationFactory = new IdentityApplicationFactory();
_identityApplicationFactory.SqliteConnection = SqliteConnection; _identityApplicationFactory.TestDatabase = TestDatabase;
_identityApplicationFactory.ManagesDatabase = false;
} }
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
@ -47,6 +51,10 @@ public class ApiApplicationFactory : WebApplicationFactoryBase<Startup>
public async Task<(string Token, string RefreshToken)> LoginWithNewAccount( public async Task<(string Token, string RefreshToken)> LoginWithNewAccount(
string email = "integration-test@bitwarden.com", string masterPasswordHash = "master_password_hash") 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( await _identityApplicationFactory.RegisterNewIdentityFactoryUserAsync(
new RegisterFinishRequestModel new RegisterFinishRequestModel
{ {
@ -73,12 +81,6 @@ public class ApiApplicationFactory : WebApplicationFactoryBase<Startup>
return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash); return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash);
} }
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
SqliteConnection!.Dispose();
}
/// <summary> /// <summary>
/// Helper for logging in via client secret. /// Helper for logging in via client secret.
/// Currently used for Secrets Manager service accounts /// Currently used for Secrets Manager service accounts

View File

@ -0,0 +1,7 @@
using Bit.IntegrationTestCommon;
#nullable enable
namespace Bit.Api.IntegrationTest.Factories;
public class SqlServerApiApplicationFactory() : ApiApplicationFactory(new SqlServerTestDatabase());

View File

@ -1,11 +1,11 @@
using Bit.Core; using Bit.Core;
using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.IntegrationTestCommon;
using Bit.IntegrationTestCommon.Factories; using Bit.IntegrationTestCommon.Factories;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.TestHost;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Bit.Events.IntegrationTest; namespace Bit.Events.IntegrationTest;
@ -13,15 +13,18 @@ namespace Bit.Events.IntegrationTest;
public class EventsApplicationFactory : WebApplicationFactoryBase<Startup> public class EventsApplicationFactory : WebApplicationFactoryBase<Startup>
{ {
private readonly IdentityApplicationFactory _identityApplicationFactory; 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 = new IdentityApplicationFactory();
_identityApplicationFactory.SqliteConnection = SqliteConnection; _identityApplicationFactory.TestDatabase = TestDatabase;
_identityApplicationFactory.ManagesDatabase = false;
} }
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
@ -42,6 +45,10 @@ public class EventsApplicationFactory : WebApplicationFactoryBase<Startup>
/// </summary> /// </summary>
public async Task<(string Token, string RefreshToken)> LoginWithNewAccount(string email = "integration-test@bitwarden.com", string masterPasswordHash = "master_password_hash") 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( await _identityApplicationFactory.RegisterNewIdentityFactoryUserAsync(
new RegisterFinishRequestModel new RegisterFinishRequestModel
{ {
@ -59,10 +66,4 @@ public class EventsApplicationFactory : WebApplicationFactoryBase<Startup>
return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash); return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash);
} }
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
SqliteConnection!.Dispose();
}
} }

View File

@ -9,7 +9,6 @@ using Bit.Infrastructure.EntityFramework.Repositories;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.TestHost;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -37,14 +36,19 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
/// <remarks> /// <remarks>
/// This will need to be set BEFORE using the <c>Server</c> property /// This will need to be set BEFORE using the <c>Server</c> property
/// </remarks> /// </remarks>
public SqliteConnection? SqliteConnection { get; set; } public ITestDatabase TestDatabase { get; set; } = new SqliteTestDatabase();
/// <summary>
/// If set to <c>true</c> the factory will manage the database lifecycle, including migrations.
/// </summary>
/// <remarks>
/// This will need to be set BEFORE using the <c>Server</c> property
/// </remarks>
public bool ManagesDatabase { get; set; } = true;
private readonly List<Action<IServiceCollection>> _configureTestServices = new(); private readonly List<Action<IServiceCollection>> _configureTestServices = new();
private readonly List<Action<IConfigurationBuilder>> _configureAppConfiguration = new(); private readonly List<Action<IConfigurationBuilder>> _configureAppConfiguration = new();
private bool _handleSqliteDisposal { get; set; }
public void SubstituteService<TService>(Action<TService> mockService) public void SubstituteService<TService>(Action<TService> mockService)
where TService : class where TService : class
{ {
@ -119,22 +123,7 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
/// </summary> /// </summary>
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
if (SqliteConnection == null) var config = new Dictionary<string, string?>
{
SqliteConnection = new SqliteConnection("DataSource=:memory:");
SqliteConnection.Open();
_handleSqliteDisposal = true;
}
builder.ConfigureAppConfiguration(c =>
{
c.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json")
.AddJsonFile("appsettings.Development.json");
c.AddUserSecrets(typeof(Identity.Startup).Assembly, optional: true);
c.AddInMemoryCollection(new Dictionary<string, string?>
{ {
// Manually insert a EF provider so that ConfigureServices will add EF repositories but we will override // Manually insert a EF provider so that ConfigureServices will add EF repositories but we will override
// DbContextOptions to use an in memory database // DbContextOptions to use an in memory database
@ -142,19 +131,18 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
{ "globalSettings:postgreSql:connectionString", "Host=localhost;Username=test;Password=test;Database=test" }, { "globalSettings:postgreSql:connectionString", "Host=localhost;Username=test;Password=test;Database=test" },
// Clear the redis connection string for distributed caching, forcing an in-memory implementation // Clear the redis connection string for distributed caching, forcing an in-memory implementation
{ "globalSettings:redis:connectionString", ""}, { "globalSettings:redis:connectionString", "" },
// Clear Storage // Clear Storage
{ "globalSettings:attachment:connectionString", null}, { "globalSettings:attachment:connectionString", null },
{ "globalSettings:events:connectionString", null}, { "globalSettings:events:connectionString", null },
{ "globalSettings:send:connectionString", null}, { "globalSettings:send:connectionString", null },
{ "globalSettings:notifications:connectionString", null}, { "globalSettings:notifications:connectionString", null },
{ "globalSettings:storage:connectionString", null}, { "globalSettings:storage:connectionString", null },
// This will force it to use an ephemeral key for IdentityServer // This will force it to use an ephemeral key for IdentityServer
{ "globalSettings:developmentDirectory", null }, { "globalSettings:developmentDirectory", null },
// Email Verification // Email Verification
{ "globalSettings:enableEmailVerification", "true" }, { "globalSettings:enableEmailVerification", "true" },
{ "globalSettings:disableUserRegistration", "false" }, { "globalSettings:disableUserRegistration", "false" },
@ -166,7 +154,20 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
// Web push notifications // Web push notifications
{ "globalSettings:webPush:vapidPublicKey", "BGBtAM0bU3b5jsB14IjBYarvJZ6rWHilASLudTTYDDBi7a-3kebo24Yus_xYeOMZ863flAXhFAbkL6GVSrxgErg" }, { "globalSettings:webPush:vapidPublicKey", "BGBtAM0bU3b5jsB14IjBYarvJZ6rWHilASLudTTYDDBi7a-3kebo24Yus_xYeOMZ863flAXhFAbkL6GVSrxgErg" },
{ "globalSettings:launchDarkly:flagValues:web-push", "true" }, { "globalSettings:launchDarkly:flagValues:web-push", "true" },
}); };
// Some database drivers modify the connection string
TestDatabase.ModifyGlobalSettings(config);
builder.ConfigureAppConfiguration(c =>
{
c.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json")
.AddJsonFile("appsettings.Development.json");
c.AddUserSecrets(typeof(Identity.Startup).Assembly, optional: true);
c.AddInMemoryCollection(config);
}); });
// Run configured actions after defaults to allow them to take precedence // Run configured actions after defaults to allow them to take precedence
@ -177,17 +178,16 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>
{ {
var dbContextOptions = services.First(sd => sd.ServiceType == typeof(DbContextOptions<DatabaseContext>)); var dbContextOptions =
services.First(sd => sd.ServiceType == typeof(DbContextOptions<DatabaseContext>));
services.Remove(dbContextOptions); services.Remove(dbContextOptions);
services.AddScoped(services =>
{
return new DbContextOptionsBuilder<DatabaseContext>()
.UseSqlite(SqliteConnection)
.UseApplicationServiceProvider(services)
.Options;
});
MigrateDbContext<DatabaseContext>(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 // 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? // should we have a fork here to leave the normal service for developers?
@ -286,22 +286,11 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
base.Dispose(disposing); base.Dispose(disposing);
if (_handleSqliteDisposal) if (ManagesDatabase)
{ {
SqliteConnection!.Dispose(); // Avoid calling Dispose twice
ManagesDatabase = false;
TestDatabase.Dispose();
} }
} }
private void MigrateDbContext<TContext>(IServiceCollection serviceCollection) where TContext : DbContext
{
var serviceProvider = serviceCollection.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
var services = scope.ServiceProvider;
var context = services.GetRequiredService<TContext>();
if (_handleSqliteDisposal)
{
context.Database.EnsureDeleted();
}
context.Database.EnsureCreated();
}
} }

View File

@ -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<string, string?> config)
{
// Default implementation does nothing
}
}

View File

@ -11,6 +11,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\Identity\Identity.csproj" /> <ProjectReference Include="..\..\src\Identity\Identity.csproj" />
<ProjectReference Include="..\..\util\Migrator\Migrator.csproj" />
<ProjectReference Include="..\Common\Common.csproj" /> <ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -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<string, string> config)
{
config["globalSettings:databaseProvider"] = "sqlserver";
config["globalSettings:sqlServer:connectionString"] = _sqlServerConnection;
}
public void AddDatabase(IServiceCollection serviceCollection)
{
serviceCollection.AddScoped(s => new DbContextOptionsBuilder<DatabaseContext>()
.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<GlobalSettings>();
var logger = services.GetRequiredService<ILogger<DbMigrator>>();
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();
}
}
}

View File

@ -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<DatabaseContext>()
.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<DatabaseContext>();
context.Database.EnsureDeleted();
context.Database.EnsureCreated();
}
public void Dispose()
{
SqliteConnection.Dispose();
}
}