feat(host): inbound API key store provider — SqlServer shares one zbauth row set across the central pair; one-shot SQLite import at boot (file renamed .imported, one retry on a concurrent-peer PK race, failure never blocks boot)
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
This commit is contained in:
@@ -364,6 +364,26 @@ public class CentralCompositionRootTests : IDisposable
|
||||
/// site writer chain is registered on central but by design never resolved there, and
|
||||
/// events written to it would dead-end in a local file no forwarder ever drains.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Register row 108: the inbound API-key store is a per-node SQLite file UNLESS
|
||||
/// <c>ScadaBridge:InboundApi:ApiKeyStore:Provider</c> says <c>SqlServer</c>. This pins the
|
||||
/// default half — an unset Provider must leave the existing SQLite composition byte-identical,
|
||||
/// including registering NO import hosted service (the one-shot SQLite→SQL copy is meaningless,
|
||||
/// and would rename the live key file out from under the node, on a SQLite-backed node).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Central_InboundApiKeyStore_DefaultsToSqlite()
|
||||
{
|
||||
Assert.IsType<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteApiKeyStore>(
|
||||
_factory.Services.GetService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStore>());
|
||||
Assert.IsType<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthStoreMigrator>(
|
||||
_factory.Services.GetService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStoreMigrator>());
|
||||
|
||||
var hostedServices = _factory.Services.GetServices<IHostedService>();
|
||||
Assert.DoesNotContain(hostedServices, s => s is InboundApiKeyStoreImportService);
|
||||
Assert.Null(_factory.Services.GetService<IInboundApiKeyStoreImporter>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_SharedAuditSeam_IsTheCentralDirectWriteBridge()
|
||||
{
|
||||
@@ -373,6 +393,214 @@ public class CentralCompositionRootTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register row 108 — the shared-store half of the inbound API-key composition. With
|
||||
/// <c>ScadaBridge:InboundApi:ApiKeyStore:Provider = SqlServer</c> both central nodes must resolve
|
||||
/// the SQL Server-backed stores (one shared row set) instead of each node's own SQLite file, so a
|
||||
/// key minted on one node authenticates on the other after a failover.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The Provider (and, in the derived fixture, the explicit connection string) is supplied as
|
||||
/// a process-wide ENVIRONMENT variable rather than through
|
||||
/// <c>ConfigureAppConfiguration</c>: <c>Program</c> reads this key while it is still
|
||||
/// registering services, and only the pre-host configuration builder — json + environment —
|
||||
/// is populated at that point. Same reason <see cref="CentralDbTestEnvironment"/> supplies
|
||||
/// its values that way, and the same reason this class must sit in
|
||||
/// <see cref="HostBootCollection"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// There is no SQL Server in a unit test, so the two hosted services that would dial one at
|
||||
/// host start are removed: the library's own migration service (which creates the
|
||||
/// <c>zbauth</c> schema) and <see cref="InboundApiKeyStoreImportService"/>. Everything under
|
||||
/// test is a registration shape, which needs no live database — every store is resolved,
|
||||
/// never called.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(HostBootCollection.Name)]
|
||||
public class CentralSqlServerKeyStoreCompositionRootTests : IDisposable
|
||||
{
|
||||
/// <summary>Set by the derived fixture to pin an explicit connection string instead of the ConfigurationDb default.</summary>
|
||||
private protected const string ExplicitConnectionString =
|
||||
"Server=explicit-host,1433;Database=ExplicitKeyStore;Trusted_Connection=true;TrustServerCertificate=true";
|
||||
|
||||
private const string ProviderEnvKey = "ScadaBridge__InboundApi__ApiKeyStore__Provider";
|
||||
private const string ConnectionStringEnvKey =
|
||||
"ScadaBridge__InboundApi__ApiKeyStore__SqlServer__ConnectionString";
|
||||
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
private readonly string? _previousEnv;
|
||||
private readonly string? _previousProvider;
|
||||
private readonly string? _previousConnectionString;
|
||||
private readonly CentralDbTestEnvironment _dbEnv;
|
||||
|
||||
/// <summary>The provider the built host actually resolved services from.</summary>
|
||||
private protected IServiceProvider Services => _factory.Services;
|
||||
|
||||
private protected CentralSqlServerKeyStoreCompositionRootTests(string? explicitConnectionString)
|
||||
{
|
||||
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||
|
||||
_previousProvider = Environment.GetEnvironmentVariable(ProviderEnvKey);
|
||||
Environment.SetEnvironmentVariable(ProviderEnvKey, "SqlServer");
|
||||
|
||||
_previousConnectionString = Environment.GetEnvironmentVariable(ConnectionStringEnvKey);
|
||||
Environment.SetEnvironmentVariable(ConnectionStringEnvKey, explicitConnectionString);
|
||||
|
||||
_dbEnv = new CentralDbTestEnvironment();
|
||||
|
||||
_factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:Node:NodeName"] = "central-a",
|
||||
["ScadaBridge:Node:NodeHostname"] = "localhost",
|
||||
["ScadaBridge:Node:RemotingPort"] = "0",
|
||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
||||
["ScadaBridge:Database:SkipMigrations"] = "true",
|
||||
["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
|
||||
["ScadaBridge:Security:Ldap:Server"] = "localhost",
|
||||
["ScadaBridge:Security:Ldap:Port"] = "3893",
|
||||
["ScadaBridge:Security:Ldap:Transport"] = "None",
|
||||
["ScadaBridge:Security:Ldap:AllowInsecure"] = "true",
|
||||
["ScadaBridge:Security:Ldap:SearchBase"] = "dc=zb,dc=local",
|
||||
["ScadaBridge:Security:Ldap:ServiceAccountDn"] = "cn=admin,dc=zb,dc=local",
|
||||
["ScadaBridge:InboundApi:ApiKeyPepper"] = "test-inbound-api-key-pepper-at-least-32-chars!",
|
||||
});
|
||||
});
|
||||
builder.UseSetting("ScadaBridge:Node:Role", "Central");
|
||||
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var descriptorsToRemove = services
|
||||
.Where(d =>
|
||||
d.ServiceType == typeof(DbContextOptions<ScadaBridgeDbContext>) ||
|
||||
d.ServiceType == typeof(DbContextOptions) ||
|
||||
d.ServiceType == typeof(ScadaBridgeDbContext) ||
|
||||
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
|
||||
.ToList();
|
||||
foreach (var d in descriptorsToRemove)
|
||||
services.Remove(d);
|
||||
|
||||
services.AddDbContext<ScadaBridgeDbContext>(options =>
|
||||
options.UseInMemoryDatabase($"SqlServerKeyStoreCompositionRootTests_{Guid.NewGuid()}"));
|
||||
|
||||
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
|
||||
|
||||
// No SQL Server here: drop the two hosted services that would dial one at
|
||||
// host start. ApiKeyMigrationHostedService is internal to the Auth library,
|
||||
// so it is matched by name.
|
||||
var sqlDialingHostedServices = services
|
||||
.Where(d => d.ServiceType == typeof(IHostedService)
|
||||
&& (d.ImplementationType?.Name == "ApiKeyMigrationHostedService"
|
||||
|| d.ImplementationType == typeof(InboundApiKeyStoreImportService)))
|
||||
.ToList();
|
||||
foreach (var d in sqlDialingHostedServices)
|
||||
services.Remove(d);
|
||||
});
|
||||
});
|
||||
|
||||
_ = _factory.Server;
|
||||
}
|
||||
|
||||
public CentralSqlServerKeyStoreCompositionRootTests()
|
||||
: this(explicitConnectionString: null)
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_factory.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
|
||||
Environment.SetEnvironmentVariable(ProviderEnvKey, _previousProvider);
|
||||
Environment.SetEnvironmentVariable(ConnectionStringEnvKey, _previousConnectionString);
|
||||
_dbEnv.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStore),
|
||||
typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyStore))]
|
||||
[InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAdminStore),
|
||||
typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyAdminStore))]
|
||||
[InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAuditStore),
|
||||
typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyAuditStore))]
|
||||
[InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStoreMigrator),
|
||||
typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyStoreMigrator))]
|
||||
public void Central_SqlServerProvider_ResolvesSharedStores(Type serviceType, Type expectedImplementation)
|
||||
{
|
||||
var service = Services.GetService(serviceType);
|
||||
Assert.NotNull(service);
|
||||
Assert.IsType(expectedImplementation, service);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The connection string defaults to the configuration database the pair already shares, under
|
||||
/// the library's own <c>zbauth</c> schema — so enabling the shared store needs no new database,
|
||||
/// no new secret and no new appsettings key beyond the Provider switch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public virtual void Central_SqlServerProvider_ConnectionStringDefaultsToConfigurationDb()
|
||||
{
|
||||
var options = Services
|
||||
.GetRequiredService<IOptions<ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyOptions>>().Value;
|
||||
var configuration = Services.GetRequiredService<IConfiguration>();
|
||||
|
||||
Assert.Equal(configuration["ScadaBridge:Database:ConfigurationDb"], options.ConnectionString);
|
||||
Assert.False(string.IsNullOrWhiteSpace(options.ConnectionString));
|
||||
Assert.Equal("zbauth", options.Schema);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The admin facade must be composable against the shared store too — its migrator argument is
|
||||
/// the provider-agnostic <c>IApiKeyStoreMigrator</c>, not the SQLite concrete type, which is
|
||||
/// exactly the registration that would have thrown here had the switch been left half-done.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Central_SqlServerProvider_ResolvesAdminCommandsAndImporter()
|
||||
{
|
||||
Assert.NotNull(Services.GetService<ZB.MOM.WW.Auth.ApiKeys.Admin.ApiKeyAdminCommands>());
|
||||
Assert.NotNull(Services.GetService<IInboundApiKeyStoreImporter>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An operator who puts the shared key store in its own database sets
|
||||
/// <c>ScadaBridge:InboundApi:ApiKeyStore:SqlServer:ConnectionString</c> explicitly; that value must
|
||||
/// win over the ConfigurationDb default fill-in, which is only ever applied to a blank key.
|
||||
/// </summary>
|
||||
[Collection(HostBootCollection.Name)]
|
||||
public sealed class CentralSqlServerKeyStoreExplicitConnectionStringTests
|
||||
: CentralSqlServerKeyStoreCompositionRootTests
|
||||
{
|
||||
public CentralSqlServerKeyStoreExplicitConnectionStringTests()
|
||||
: base(ExplicitConnectionString)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[Fact]
|
||||
public override void Central_SqlServerProvider_ConnectionStringDefaultsToConfigurationDb()
|
||||
{
|
||||
var options = Services
|
||||
.GetRequiredService<IOptions<ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyOptions>>().Value;
|
||||
var configuration = Services.GetRequiredService<IConfiguration>();
|
||||
|
||||
Assert.Equal(ExplicitConnectionString, options.ConnectionString);
|
||||
Assert.NotEqual(configuration["ScadaBridge:Database:ConfigurationDb"], options.ConnectionString);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies every expected DI service resolves from the Site composition root.
|
||||
/// Uses the extracted SiteServiceRegistration.Configure() so the test always
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.Auth.ApiKeys.SqlServer;
|
||||
using ZB.MOM.WW.ScadaBridge.Host;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="InboundApiKeyStoreImportService"/> — the one-shot copy of a node's
|
||||
/// legacy per-node SQLite key file into the shared SQL Server store (register row 108).
|
||||
/// The real importer is replaced by <see cref="FakeImporter"/> so every case runs without a
|
||||
/// SQL Server: the behaviour under test is path selection, the rename-on-success, the single
|
||||
/// PK-violation retry, and the "never take the node down" exception posture.
|
||||
/// </summary>
|
||||
public sealed class InboundApiKeyStoreImportServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public InboundApiKeyStoreImportServiceTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"sb_apikey_import_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(_dir, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Best-effort temp cleanup; a locked file must not fail the suite.
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_FilePresent_ImportsThenRenamesFileAndSidecars()
|
||||
{
|
||||
var path = WriteFile("inbound-api-keys.sqlite");
|
||||
WriteFile("inbound-api-keys.sqlite-wal");
|
||||
WriteFile("inbound-api-keys.sqlite-shm");
|
||||
|
||||
var importer = new FakeImporter();
|
||||
var service = CreateService(importer, sqlitePath: path);
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(new[] { path }, importer.Calls);
|
||||
Assert.False(File.Exists(path));
|
||||
Assert.True(File.Exists(path + ".imported"));
|
||||
Assert.False(File.Exists(path + "-wal"));
|
||||
Assert.True(File.Exists(path + "-wal.imported"));
|
||||
Assert.False(File.Exists(path + "-shm"));
|
||||
Assert.True(File.Exists(path + "-shm.imported"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_FileAbsent_DoesNotCallImporter()
|
||||
{
|
||||
var importer = new FakeImporter();
|
||||
var service = CreateService(importer, sqlitePath: Path.Combine(_dir, "missing.sqlite"));
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Empty(importer.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_BlankPath_DoesNotCallImporter()
|
||||
{
|
||||
var importer = new FakeImporter();
|
||||
var service = CreateService(importer, sqlitePath: " ");
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Empty(importer.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_ImportFromSqlitePath_OverridesApiKeyOptionsPath()
|
||||
{
|
||||
var apiKeyPath = WriteFile("api-key-options.sqlite");
|
||||
var overridePath = WriteFile("explicit-override.sqlite");
|
||||
|
||||
var importer = new FakeImporter();
|
||||
var service = CreateService(importer, sqlitePath: apiKeyPath, importFrom: overridePath);
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(new[] { overridePath }, importer.Calls);
|
||||
Assert.True(File.Exists(apiKeyPath)); // untouched
|
||||
Assert.True(File.Exists(overridePath + ".imported"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_ImporterThrows_DoesNotThrowAndLeavesFileInPlace()
|
||||
{
|
||||
var path = WriteFile("inbound-api-keys.sqlite");
|
||||
|
||||
var importer = new FakeImporter { ThrowOnCall = _ => new InvalidOperationException("boom") };
|
||||
var service = CreateService(importer, sqlitePath: path);
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Single(importer.Calls);
|
||||
Assert.True(File.Exists(path)); // left for the next boot to retry
|
||||
Assert.False(File.Exists(path + ".imported"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_PrimaryKeyViolation_RetriesOnceThenSucceeds()
|
||||
{
|
||||
var path = WriteFile("inbound-api-keys.sqlite");
|
||||
|
||||
var importer = new FakeImporter
|
||||
{
|
||||
ThrowOnCall = call => call == 1 ? FabricateSqlException("duplicate key", number: 2627) : null,
|
||||
};
|
||||
var service = CreateService(importer, sqlitePath: path);
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, importer.Calls.Count);
|
||||
Assert.True(File.Exists(path + ".imported"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_PrimaryKeyViolationTwice_DoesNotThrowAndLeavesFileInPlace()
|
||||
{
|
||||
var path = WriteFile("inbound-api-keys.sqlite");
|
||||
|
||||
var importer = new FakeImporter
|
||||
{
|
||||
ThrowOnCall = _ => FabricateSqlException("duplicate key", number: 2627),
|
||||
};
|
||||
var service = CreateService(importer, sqlitePath: path);
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, importer.Calls.Count);
|
||||
Assert.True(File.Exists(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_NonPrimaryKeySqlException_DoesNotRetry()
|
||||
{
|
||||
var path = WriteFile("inbound-api-keys.sqlite");
|
||||
|
||||
var importer = new FakeImporter
|
||||
{
|
||||
ThrowOnCall = _ => FabricateSqlException("login failed", number: 18456),
|
||||
};
|
||||
var service = CreateService(importer, sqlitePath: path);
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Single(importer.Calls);
|
||||
Assert.True(File.Exists(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_StaleImportedFilePresent_IsOverwritten()
|
||||
{
|
||||
var path = WriteFile("inbound-api-keys.sqlite", content: "fresh");
|
||||
WriteFile("inbound-api-keys.sqlite.imported", content: "stale");
|
||||
|
||||
var importer = new FakeImporter();
|
||||
var service = CreateService(importer, sqlitePath: path);
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.False(File.Exists(path));
|
||||
Assert.Equal("fresh", File.ReadAllText(path + ".imported"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_IsANoOp()
|
||||
{
|
||||
var importer = new FakeImporter();
|
||||
var service = CreateService(importer, sqlitePath: Path.Combine(_dir, "missing.sqlite"));
|
||||
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Empty(importer.Calls);
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private string WriteFile(string name, string content = "x")
|
||||
{
|
||||
var path = Path.Combine(_dir, name);
|
||||
File.WriteAllText(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static InboundApiKeyStoreImportService CreateService(
|
||||
IInboundApiKeyStoreImporter importer, string sqlitePath, string? importFrom = null)
|
||||
=> new(
|
||||
importer,
|
||||
Options.Create(new InboundApiKeyStoreOptions
|
||||
{
|
||||
Provider = InboundApiKeyStoreProvider.SqlServer,
|
||||
ImportFromSqlitePath = importFrom,
|
||||
}),
|
||||
Options.Create(new ApiKeyOptions { SqlitePath = sqlitePath }),
|
||||
NullLogger<InboundApiKeyStoreImportService>.Instance);
|
||||
|
||||
private sealed class FakeImporter : IInboundApiKeyStoreImporter
|
||||
{
|
||||
private int _calls;
|
||||
|
||||
public List<string> Calls { get; } = new();
|
||||
|
||||
/// <summary>Given the 1-based call number, returns the exception to throw, or null to succeed.</summary>
|
||||
public Func<int, Exception?>? ThrowOnCall { get; init; }
|
||||
|
||||
public Task<ApiKeyImportResult> ImportAsync(string sqlitePath, CancellationToken ct)
|
||||
{
|
||||
Calls.Add(sqlitePath);
|
||||
var call = ++_calls;
|
||||
var toThrow = ThrowOnCall?.Invoke(call);
|
||||
if (toThrow is not null)
|
||||
{
|
||||
return Task.FromException<ApiKeyImportResult>(toThrow);
|
||||
}
|
||||
|
||||
return Task.FromResult(new ApiKeyImportResult(1, 0, 2, 0, SourceFound: true));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fabricates a <see cref="Microsoft.Data.SqlClient.SqlException"/> with a given message and
|
||||
/// error number via the driver's internal <c>CreateException</c> factory (the type has no
|
||||
/// public constructor). Same recipe as <c>DatabaseGatewayTests.FabricateSqlException</c>;
|
||||
/// duplicated because the two suites share no test-support assembly.
|
||||
/// </summary>
|
||||
private static Microsoft.Data.SqlClient.SqlException FabricateSqlException(string message, int number)
|
||||
{
|
||||
var errorCtor = typeof(Microsoft.Data.SqlClient.SqlError).GetConstructors(
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
|
||||
.First(c => c.GetParameters().Length == 8
|
||||
&& c.GetParameters()[7].ParameterType == typeof(Exception));
|
||||
var sqlError = (Microsoft.Data.SqlClient.SqlError)errorCtor.Invoke(
|
||||
new object?[] { number, (byte)0, (byte)0, "server", message, "procedure", 0, null });
|
||||
|
||||
var collectionCtor = typeof(Microsoft.Data.SqlClient.SqlErrorCollection).GetConstructors(
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).First();
|
||||
var collection = (Microsoft.Data.SqlClient.SqlErrorCollection)collectionCtor.Invoke(Array.Empty<object?>());
|
||||
typeof(Microsoft.Data.SqlClient.SqlErrorCollection)
|
||||
.GetMethod("Add", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
|
||||
.Invoke(collection, new object?[] { sqlError });
|
||||
|
||||
var createException = typeof(Microsoft.Data.SqlClient.SqlException).GetMethod(
|
||||
"CreateException",
|
||||
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic,
|
||||
new[] { typeof(Microsoft.Data.SqlClient.SqlErrorCollection), typeof(string) })!;
|
||||
return (Microsoft.Data.SqlClient.SqlException)createException.Invoke(
|
||||
null, new object?[] { collection, "6.0.0" })!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user