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; /// /// Unit tests for — 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 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. /// 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.Instance); private sealed class FakeImporter : IInboundApiKeyStoreImporter { private int _calls; public List Calls { get; } = new(); /// Given the 1-based call number, returns the exception to throw, or null to succeed. public Func? ThrowOnCall { get; init; } public Task ImportAsync(string sqlitePath, CancellationToken ct) { Calls.Add(sqlitePath); var call = ++_calls; var toThrow = ThrowOnCall?.Invoke(call); if (toThrow is not null) { return Task.FromException(toThrow); } return Task.FromResult(new ApiKeyImportResult(1, 0, 2, 0, SourceFound: true)); } } /// /// Fabricates a with a given message and /// error number via the driver's internal CreateException factory (the type has no /// public constructor). Same recipe as DatabaseGatewayTests.FabricateSqlException; /// duplicated because the two suites share no test-support assembly. /// 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()); 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" })!; } }