fix(driver-galaxy): resolve Low code-review findings (Driver.Galaxy-005,010,012,013)

- Driver.Galaxy-005: rewrite the EventPump BoundedChannelOptions comment
  to honestly describe the Wait+TryWrite pattern.
- Driver.Galaxy-010: ResolveApiKey now warns when a literal API key is
  used in production wiring; added an explicit dev: prefix for known
  cleartext-in-dev cases and rewrote the GalaxyGatewayOptions doc.
- Driver.Galaxy-012: O(1) reverse-lookup for SubscriptionRegistry
  dispatch via per-entry FullRefByItemHandle map; immutable hash-set for
  the cross-binding reverse map; SubscribeAsync / ReadViaSubscribeOnce
  use BuildResultIndex for per-reference correlation.
- Driver.Galaxy-013: ReinitializeAsync now validates the incoming JSON
  against the running options; ReplayOnSessionLost honoured by the
  Replay path; class summary rewritten to describe the shipped surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 07:45:08 -04:00
parent 5c513f99fd
commit 9f7ae20995
9 changed files with 444 additions and 58 deletions

View File

@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
@@ -69,6 +70,61 @@ public sealed class GalaxyDriverApiKeyResolverTests
ex.Message.ShouldContain("doesn't exist");
}
// ===== Driver.Galaxy-010 regression: literal arm warns + dev: prefix path =====
[Fact]
public void Literal_string_emits_warning_when_logger_supplied()
{
// A literal API key on a production deployment means the cleartext key sits
// in the DriverConfig JSON. The resolver must surface a warning so an
// operator who committed one by accident sees it at startup.
var logger = new CaptureLogger();
var key = GalaxyDriver.ResolveApiKey("plain-text-key", logger);
key.ShouldBe("plain-text-key");
logger.Entries.ShouldContain(e =>
e.Level == LogLevel.Warning && e.Message.Contains("literal", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Dev_prefix_returns_literal_without_warning()
{
// An explicit dev: prefix signals the operator knowingly opted into a literal
// key (dev / parity rig). The resolver must accept it AND suppress the
// warning so production logs aren't polluted on a deliberate dev choice.
var logger = new CaptureLogger();
var key = GalaxyDriver.ResolveApiKey("dev:plain-text-key", logger);
key.ShouldBe("plain-text-key");
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
}
[Fact]
public void Env_prefix_does_not_emit_literal_warning()
{
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_NOWARN";
Environment.SetEnvironmentVariable(name, "v");
try
{
var logger = new CaptureLogger();
GalaxyDriver.ResolveApiKey($"env:{name}", logger);
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
}
finally
{
Environment.SetEnvironmentVariable(name, null);
}
}
private sealed class CaptureLogger : ILogger
{
public List<(LogLevel Level, string Message)> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
=> Entries.Add((logLevel, formatter(state, exception)));
}
[Fact]
public void File_prefix_empty_file_throws()
{

View File

@@ -141,17 +141,29 @@ public sealed class GalaxyDriverFactoryTests
}
[Fact]
public async Task ReinitializeAsync_RefreshesHealth()
public async Task ReinitializeAsync_RefreshesHealth_WhenConfigIsEquivalent()
{
// Driver.Galaxy-013: ReinitializeAsync now compares the incoming JSON to the
// live options. An equivalent config is accepted and refreshes health; a
// non-equivalent reapply throws NotSupportedException (covered in
// GalaxyDriverInfrastructureTests.ReinitializeAsync_RejectsNonEquivalentConfigChange).
// Build a config JSON whose parsed shape equals BuildOptions() so the
// equivalence check passes.
const string equivalentConfig = """
{
"Gateway": { "Endpoint": "https://mxgw.test:5001", "ApiKeySecretRef": "key" },
"MxAccess": { "ClientName": "OtOpcUa-A" }
}
""";
using var driver = new GalaxyDriver(
"galaxy-x", BuildOptions(), hierarchySource: null, dataReader: null,
dataWriter: null, subscriber: new NoopSubscriber());
await driver.InitializeAsync(MinimalConfig, CancellationToken.None);
await driver.InitializeAsync(equivalentConfig, CancellationToken.None);
var firstStamp = driver.GetHealth().LastSuccessfulRead!.Value;
// Force a measurable clock delta so the comparison is stable on fast machines.
await Task.Delay(20);
await driver.ReinitializeAsync(MinimalConfig, CancellationToken.None);
await driver.ReinitializeAsync(equivalentConfig, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(firstStamp);

View File

@@ -85,6 +85,121 @@ public sealed class GalaxyDriverInfrastructureTests
await Should.NotThrowAsync(async () => await driver.DisposeAsync());
}
// ===== Driver.Galaxy-013 regression: ReplayOnSessionLost gates the replay step =====
[Fact]
public async Task ReplayOnSessionLost_False_SkipsResubscribeBulk()
{
// ReplayOnSessionLost was a dangling option — defined + documented but never
// read. After the fix, setting it to false makes the reconnect replay path
// skip SubscribeBulk (operator opts out of replay; the gateway's session-level
// ReplaySubscriptions handles state restoration).
var sub = new ReplayCountingSubscriber();
var opts = new GalaxyDriverOptions(
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
new GalaxyMxAccessOptions("InfraTest"),
new GalaxyRepositoryOptions(WatchDeployEvents: false),
new GalaxyReconnectOptions(ReplayOnSessionLost: false));
using var driver = new GalaxyDriver("drv-1", opts, null, null, null, sub);
// Establish a subscription so the replay path has something to walk.
await driver.SubscribeAsync(["Tag.A", "Tag.B"], TimeSpan.Zero, CancellationToken.None);
sub.SubscribeCalls.ShouldBe(1);
// Invoke the replay path directly via the internal test seam — the supervisor's
// ReportTransportFailure spins it up async; for a deterministic assertion we
// call the helper that ReplayAsync is wired against.
await driver.InvokeReplayForTestAsync(CancellationToken.None);
sub.SubscribeCalls.ShouldBe(1,
"ReplayOnSessionLost=false must skip the re-SubscribeBulk fan-out on reconnect");
}
[Fact]
public async Task ReplayOnSessionLost_True_RunsResubscribeBulk()
{
var sub = new ReplayCountingSubscriber();
var opts = new GalaxyDriverOptions(
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
new GalaxyMxAccessOptions("InfraTest"),
new GalaxyRepositoryOptions(WatchDeployEvents: false),
new GalaxyReconnectOptions(ReplayOnSessionLost: true));
using var driver = new GalaxyDriver("drv-1", opts, null, null, null, sub);
await driver.SubscribeAsync(["Tag.A"], TimeSpan.Zero, CancellationToken.None);
sub.SubscribeCalls.ShouldBe(1);
await driver.InvokeReplayForTestAsync(CancellationToken.None);
sub.SubscribeCalls.ShouldBe(2,
"default ReplayOnSessionLost=true must re-issue SubscribeBulk after a transport drop");
}
private sealed class ReplayCountingSubscriber : IGalaxySubscriber
{
private readonly Channel<MxEvent> _stream = Channel.CreateUnbounded<MxEvent>();
private int _nextHandle = 1;
public int SubscribeCalls;
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
Interlocked.Increment(ref SubscribeCalls);
var results = fullReferences.Select(r => new SubscribeResult
{
TagAddress = r,
ItemHandle = Interlocked.Increment(ref _nextHandle),
WasSuccessful = true,
}).ToList();
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
}
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
}
// ===== Driver.Galaxy-013 regression: ReinitializeAsync rejects unsupported reapply =====
[Fact]
public async Task ReinitializeAsync_RejectsNonEquivalentConfigChange()
{
// ReinitializeAsync was previously a silent no-op that ignored driverConfigJson.
// After the fix it either applies an equivalent config (no-op) or throws
// NotSupportedException so a config change isn't silently dropped.
var sub = new NoOpSubscriber();
using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
const string newConfig = "{\"Gateway\":{\"Endpoint\":\"https://other.test:5001\",\"ApiKeySecretRef\":\"dev:other\"}}";
// The driver must NOT pretend the change was applied — either no-op equivalence
// or an explicit rejection is acceptable. Silently dropping the new config
// (the previous behaviour) is not.
await Should.ThrowAsync<NotSupportedException>(async () =>
await driver.ReinitializeAsync(newConfig, CancellationToken.None));
}
[Fact]
public async Task ReinitializeAsync_AcceptsEquivalentConfig()
{
var sub = new NoOpSubscriber();
using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
// An empty / null-equivalent config reapply (no field changes) must not throw —
// it's a legitimate "refresh health" path. Pass a JSON object that round-trips
// to the driver's current options.
var json = "{\"Gateway\":{\"Endpoint\":\"https://mxgw.test:5001\",\"ApiKeySecretRef\":\"key\"}," +
"\"MxAccess\":{\"ClientName\":\"InfraTest\"}," +
"\"Repository\":{\"WatchDeployEvents\":false}," +
"\"Reconnect\":{}}";
await Should.NotThrowAsync(async () =>
await driver.ReinitializeAsync(json, CancellationToken.None));
}
// ===== Minimal IGalaxySubscriber fake that returns empty results for subscribe calls =====
private sealed class NoOpSubscriber : IGalaxySubscriber

View File

@@ -183,6 +183,36 @@ public sealed class SubscriptionRegistryTests
registry.ResolveSubscribers(0).ShouldBeEmpty();
}
// ===== Driver.Galaxy-012 regression: ResolveSubscribers is O(1) per binding =====
[Fact]
public void ResolveSubscribers_LargeBindingSet_DispatchesCorrectly()
{
// 5000-tag subscription. ResolveSubscribers must still return the right
// full-reference for any item handle without a linear scan of the entire
// binding list — the old FirstOrDefault(b => b.ItemHandle == h) was O(n)
// per dispatch, so 50k tags × 1Hz fan-out was 50k linear scans per second.
var registry = new SubscriptionRegistryAccess();
const int tagCount = 5000;
var bindings = new List<TagBindingAccess>(tagCount);
for (var i = 0; i < tagCount; i++)
{
bindings.Add(new TagBindingAccess($"Tag.{i}", 1000 + i));
}
registry.Register(1, bindings);
// Pull the last entry — the worst case for a linear scan.
var subs = registry.ResolveSubscribers(1000 + tagCount - 1);
subs.Count.ShouldBe(1);
subs[0].FullReference.ShouldBe($"Tag.{tagCount - 1}");
// Mid-range entry too — proves the index isn't position-dependent.
var mid = registry.ResolveSubscribers(1000 + tagCount / 2);
mid.Count.ShouldBe(1);
mid[0].FullReference.ShouldBe($"Tag.{tagCount / 2}");
}
// Internal types are accessed via friend assembly (InternalsVisibleTo); these
// wrapper aliases keep the test code readable.
private sealed class SubscriptionRegistryAccess