06be0b9e64
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
263 lines
9.7 KiB
C#
263 lines
9.7 KiB
C#
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" })!;
|
|
}
|
|
}
|