Merge fix/815-credential-ref-scoping
# Conflicts: # .claude/rules/drivers.md # docs/drivers/Heidenhain.md
This commit is contained in:
+78
@@ -0,0 +1,78 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#815: the AdminUI live browse (Galaxy / OpcUaClient bespoke browsers, and the Discover-backed universal
|
||||
/// browser) resolves the config's credential references and sends them to the author-chosen endpoint — an
|
||||
/// out-of-namespace reference is refused before any browser is opened.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BrowserSessionCredentialScopeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")]
|
||||
[InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://evil:4840","Password":"secret:otopcua/historian/api-key"}""")]
|
||||
[InlineData(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"secret:galaxy/main/apikey"}}""")]
|
||||
public async Task Out_of_namespace_reference_never_opens_a_browser(string driverType, string config)
|
||||
{
|
||||
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
|
||||
var bespoke2 = new RecordingBrowser(DriverTypeNames.OpcUaClient);
|
||||
var universal = new RecordingUniversal();
|
||||
var svc = new BrowserSessionService(
|
||||
[bespoke, bespoke2], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, universal, null!, null!);
|
||||
|
||||
var result = await svc.OpenAsync(driverType, config, TestContext.Current.CancellationToken);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull().ShouldContain("lmxopcua#815");
|
||||
bespoke.Opens.ShouldBe(0);
|
||||
bespoke2.Opens.ShouldBe(0);
|
||||
universal.Opens.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a namespaced reference reaches the browser.</summary>
|
||||
[Fact]
|
||||
public async Task A_namespaced_reference_reaches_the_browser()
|
||||
{
|
||||
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
|
||||
var svc = new BrowserSessionService(
|
||||
[bespoke], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, new RecordingUniversal(), null!, null!);
|
||||
|
||||
await svc.OpenAsync(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/main/apikey"}}""",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
bespoke.Opens.ShouldBe(1);
|
||||
}
|
||||
|
||||
private sealed class RecordingBrowser(string driverType) : IDriverBrowser
|
||||
{
|
||||
public int Opens { get; private set; }
|
||||
|
||||
public string DriverType => driverType;
|
||||
|
||||
public Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
|
||||
{
|
||||
Opens++;
|
||||
throw new InvalidOperationException("opened");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingUniversal : IUniversalDriverBrowser
|
||||
{
|
||||
public int Opens { get; private set; }
|
||||
|
||||
public bool CanBrowse(string driverType, string configJson) => true;
|
||||
|
||||
public Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct)
|
||||
{
|
||||
Opens++;
|
||||
throw new InvalidOperationException("opened");
|
||||
}
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Deployments;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#815 upgrade preflight: stored (draft) and deployed configs with out-of-namespace credential references
|
||||
/// are listed and logged — driver, device, field, allowed form — and never the value; nothing is resolved.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CredentialReferencePreflightTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private const string LegacyGalaxy = """{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"env:MX_API_KEY"}}""";
|
||||
|
||||
private static string SeedLegacy()
|
||||
{
|
||||
var name = RawTreeTestDb.SeedFresh();
|
||||
using var db = RawTreeTestDb.CreateNamed(name);
|
||||
// Written straight to the DB, as a pre-#815 build would have — the save gate now refuses it.
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "gx-old", ClusterId = RawTreeTestDb.ClusterId, Name = "Galaxy main",
|
||||
DriverType = DriverTypeNames.Galaxy, DriverConfig = LegacyGalaxy,
|
||||
});
|
||||
db.Devices.Add(new Device
|
||||
{
|
||||
DeviceId = "ouc-dev", DriverInstanceId = "gx-old", Name = "gw1",
|
||||
DeviceConfig = """{"AlarmAck":{"FallbackPasswordSecretRef":"file:C:\\keys\\ack.txt"}}""",
|
||||
});
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "mb", ClusterId = RawTreeTestDb.ClusterId, Name = "modbus",
|
||||
DriverType = DriverTypeNames.Modbus, DriverConfig = """{"Password":"env:ConnectionStrings__ConfigDb"}""",
|
||||
});
|
||||
var artifact = """
|
||||
{"DriverInstances":[{"DriverInstanceId":"ouc","Name":"Upstream","DriverType":"OpcUaClient",
|
||||
"DriverConfig":"{\"Password\":\"secret:plant/opcua-pw\"}"}]}
|
||||
""";
|
||||
db.Deployments.Add(new Deployment { RevisionHash = "abc", CreatedBy = "t", ArtifactBlob = Encoding.UTF8.GetBytes(artifact) });
|
||||
db.SaveChanges();
|
||||
return name;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Scan_lists_draft_rows_devices_and_the_deployed_artifact_without_values()
|
||||
{
|
||||
var findings = await new CredentialReferencePreflight(RawTreeTestDb.Factory(SeedLegacy())).ScanAsync(Ct);
|
||||
|
||||
findings.ShouldContain(f => f.Source == CredentialFindingSource.Draft && f.DriverName == "Galaxy main"
|
||||
&& f.DeviceName == null && f.Problem.Contains("Gateway.ApiKeySecretRef")
|
||||
&& f.Problem.Contains("env:OTOPCUA_GALAXY_<NAME>"));
|
||||
findings.ShouldContain(f => f.Source == CredentialFindingSource.Draft && f.DeviceName == "gw1"
|
||||
&& f.Problem.Contains("AlarmAck.FallbackPasswordSecretRef"));
|
||||
findings.ShouldContain(f => f.Source == CredentialFindingSource.Deployed && f.DriverName == "Upstream"
|
||||
&& f.Problem.Contains("secret:opcuaclient/<name>"));
|
||||
findings.ShouldNotContain(f => f.DriverInstanceId == "mb"); // not a credential-resolving driver
|
||||
findings.ShouldAllBe(f => !f.Problem.Contains("MX_API_KEY") && !f.Problem.Contains("plant/opcua-pw")
|
||||
&& !f.Problem.Contains("ack.txt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_clean_database_has_no_findings()
|
||||
=> (await new CredentialReferencePreflight(RawTreeTestDb.Factory(RawTreeTestDb.SeedFresh())).ScanAsync(Ct)).ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public async Task The_boot_pass_logs_one_warning_per_finding_and_publishes_the_state()
|
||||
{
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton(RawTreeTestDb.Factory(SeedLegacy()))
|
||||
.BuildServiceProvider();
|
||||
var state = new CredentialReferencePreflightState();
|
||||
var logger = new CaptureLogger();
|
||||
var svc = new CredentialReferencePreflightService(services, new FakeLifetime(), state, logger) { StartDelay = TimeSpan.Zero };
|
||||
|
||||
var findings = await svc.RunOnceAsync(Ct);
|
||||
|
||||
findings.Count.ShouldBe(3);
|
||||
state.Current.Findings.Count.ShouldBe(3);
|
||||
state.Current.ScannedAtUtc.ShouldNotBeNull();
|
||||
var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList();
|
||||
warnings.Count.ShouldBe(3);
|
||||
warnings.ShouldAllBe(w => w.Message.Contains("lmxopcua#815") && !w.Message.Contains("MX_API_KEY"));
|
||||
warnings.ShouldContain(w => w.Message.Contains("Galaxy main") && w.Message.Contains("Gateway.ApiKeySecretRef"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_node_without_a_config_db_skips_quietly()
|
||||
{
|
||||
var svc = new CredentialReferencePreflightService(
|
||||
new ServiceCollection().BuildServiceProvider(), new FakeLifetime(), new CredentialReferencePreflightState(), new CaptureLogger());
|
||||
(await svc.RunOnceAsync(Ct)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_hosted_service_waits_for_ApplicationStarted_before_scanning()
|
||||
{
|
||||
var services = new ServiceCollection().AddSingleton(RawTreeTestDb.Factory(SeedLegacy())).BuildServiceProvider();
|
||||
var lifetime = new FakeLifetime();
|
||||
var state = new CredentialReferencePreflightState();
|
||||
var svc = new CredentialReferencePreflightService(services, lifetime, state, new CaptureLogger()) { StartDelay = TimeSpan.Zero };
|
||||
|
||||
await svc.StartAsync(Ct);
|
||||
await Task.Delay(100, Ct);
|
||||
state.Current.ScannedAtUtc.ShouldBeNull(); // nothing before the host has started
|
||||
|
||||
lifetime.Start();
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (state.Current.ScannedAtUtc is null && DateTime.UtcNow < deadline) await Task.Delay(20, Ct);
|
||||
state.Current.Findings.Count.ShouldBe(3);
|
||||
await svc.StopAsync(Ct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Running_drivers_reporting_warn_only_resolution_are_surfaced_from_driver_health()
|
||||
{
|
||||
var snapshots = new[]
|
||||
{
|
||||
new DriverHealthChanged("c", "gx-old", "Healthy", null, null, 0, DateTime.UtcNow,
|
||||
Diagnostics: new Dictionary<string, string>
|
||||
{
|
||||
[LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "Gateway.ApiKeySecretRef"] = "deprecated …",
|
||||
["s7.pdu"] = "480",
|
||||
}),
|
||||
new DriverHealthChanged("c", "mb", "Healthy", null, null, 0, DateTime.UtcNow),
|
||||
};
|
||||
|
||||
var running = CredentialReferencePreflight.RunningFindings(snapshots);
|
||||
|
||||
running.ShouldHaveSingleItem().ShouldSatisfyAllConditions(
|
||||
f => f.Source.ShouldBe(CredentialFindingSource.Running),
|
||||
f => f.DriverInstanceId.ShouldBe("gx-old"),
|
||||
f => f.Problem.ShouldStartWith("Gateway.ApiKeySecretRef"));
|
||||
}
|
||||
|
||||
private sealed class FakeLifetime : IHostApplicationLifetime
|
||||
{
|
||||
private readonly CancellationTokenSource _started = new();
|
||||
|
||||
public CancellationToken ApplicationStarted => _started.Token;
|
||||
|
||||
public CancellationToken ApplicationStopping => CancellationToken.None;
|
||||
|
||||
public CancellationToken ApplicationStopped => CancellationToken.None;
|
||||
|
||||
public void Start() => _started.Cancel();
|
||||
|
||||
public void StopApplication()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CaptureLogger : ILogger<CredentialReferencePreflightService>
|
||||
{
|
||||
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)
|
||||
{
|
||||
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#815 at the SAVE path (RawTreeService): a Galaxy / OpcUaClient credential reference outside the
|
||||
/// driver's namespace never reaches the config DB, through driver and device create + update.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CredentialReferenceSavePathTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"file:appsettings.json"}}""")]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/apikeys/ab/galaxy-ack-password"}}""")]
|
||||
[InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://evil:4840","Password":"secret:otopcua/historian/api-key"}""")]
|
||||
[InlineData(DriverTypeNames.OpcUaClient, """{"UserCertificatePassword":"env:ServerHistorian__ApiKey"}""")]
|
||||
public async Task CreateDriver_refuses_an_out_of_namespace_reference_and_persists_nothing(string type, string config)
|
||||
{
|
||||
var name = RawTreeTestDb.SeedFresh();
|
||||
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
|
||||
var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "d815", type, config, Ct);
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error!.ShouldContain("lmxopcua#815");
|
||||
using var db = RawTreeTestDb.CreateNamed(name);
|
||||
db.DriverInstances.ShouldNotContain(d => d.Name == "d815");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_paths_refuse_and_keep_the_stored_config()
|
||||
{
|
||||
var name = RawTreeTestDb.SeedFresh();
|
||||
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
|
||||
var created = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "gx", DriverTypeNames.Galaxy,
|
||||
"""{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"secret:galaxy/gx/apikey"}}""", Ct);
|
||||
created.Ok.ShouldBeTrue(created.Error);
|
||||
|
||||
byte[] rv;
|
||||
string deviceId;
|
||||
byte[] deviceRv;
|
||||
using (var db = RawTreeTestDb.CreateNamed(name))
|
||||
{
|
||||
rv = db.DriverInstances.Single(d => d.DriverInstanceId == created.CreatedId).RowVersion;
|
||||
var device = db.Devices.Single(d => d.DriverInstanceId == created.CreatedId);
|
||||
deviceId = device.DeviceId;
|
||||
deviceRv = device.RowVersion;
|
||||
}
|
||||
|
||||
(await svc.UpdateDriverAsync(created.CreatedId!, "gx",
|
||||
"""{"Gateway":{"ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""", null, rv, Ct)).Ok.ShouldBeFalse();
|
||||
(await svc.UpdateDeviceAsync(deviceId, "dev",
|
||||
"""{"Gateway":{"ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""", true, deviceRv, Ct)).Ok.ShouldBeFalse();
|
||||
(await svc.CreateDeviceAsync(created.CreatedId!, "dev2",
|
||||
"""{"Gateway":{"ApiKeySecretRef":"secret:otopcua/historian/api-key"}}""", Ct)).Ok.ShouldBeFalse();
|
||||
|
||||
using var check = RawTreeTestDb.CreateNamed(name);
|
||||
check.DriverInstances.Single(d => d.DriverInstanceId == created.CreatedId).DriverConfig.ShouldContain("secret:galaxy/gx/apikey");
|
||||
check.Devices.Where(d => d.DriverInstanceId == created.CreatedId)
|
||||
.ShouldAllBe(d => !d.DeviceConfig.Contains("ConnectionStrings") && !d.DeviceConfig.Contains("historian"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"},"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/galaxy/ack-fallback"}}""")]
|
||||
[InlineData(DriverTypeNames.OpcUaClient, """{"Password":"literal-password","UserCertificatePassword":"secret:opcuaclient/plant/cert"}""")]
|
||||
public async Task Namespaced_references_save(string type, string config)
|
||||
{
|
||||
var name = RawTreeTestDb.SeedFresh();
|
||||
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
|
||||
var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "ok815", type, config, Ct);
|
||||
result.Ok.ShouldBeTrue(result.Error);
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#815: Test Connect with a credential reference outside the driver's namespace is refused BEFORE any
|
||||
/// probe runs — the probe is the seam that would resolve the reference (store / environment) and send the value
|
||||
/// to the author-chosen endpoint, so "the probe was never invoked" is "nothing was read".
|
||||
/// </summary>
|
||||
public sealed class AdminOperationsActorCredentialScopeTests : ControlPlaneActorTestBase
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:MX_API_KEY"}}""")]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ServerHistorian__ApiKey"}}""")]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"file:appsettings.json"}}""")]
|
||||
[InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://attacker:4840","Password":"secret:otopcua/historian/api-key"}""")]
|
||||
[InlineData(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"secret:galaxy/main/apikey"},"Devices":[{"Host":"attacker"}]}""")]
|
||||
public void Out_of_namespace_reference_never_reaches_the_probe(string driverType, string config)
|
||||
{
|
||||
var probe = new RecordingProbe(driverType);
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
|
||||
|
||||
actor.Tell(new TestDriverConnect(driverType, config, 5, Guid.NewGuid()));
|
||||
|
||||
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
|
||||
reply.Ok.ShouldBeFalse();
|
||||
reply.Message.ShouldNotBeNull().ShouldContain("Test Connect refused");
|
||||
reply.Message.ShouldContain("lmxopcua#815");
|
||||
probe.Calls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a namespaced reference does reach the probe (the refusal is not blanket).</summary>
|
||||
[Fact]
|
||||
public void A_namespaced_reference_reaches_the_probe()
|
||||
{
|
||||
var probe = new RecordingProbe(DriverTypeNames.Galaxy);
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
|
||||
|
||||
actor.Tell(new TestDriverConnect(
|
||||
DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"secret:galaxy/main/apikey"}}""", 5, Guid.NewGuid()));
|
||||
|
||||
ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken).Ok.ShouldBeTrue();
|
||||
probe.Calls.ShouldBe(1);
|
||||
}
|
||||
|
||||
private sealed class RecordingProbe(string driverType) : IDriverProbe
|
||||
{
|
||||
private int _calls;
|
||||
|
||||
public int Calls => Volatile.Read(ref _calls);
|
||||
|
||||
public string DriverType => driverType;
|
||||
|
||||
public Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _calls);
|
||||
return Task.FromResult(new DriverProbeResult(true, null, TimeSpan.Zero));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user