8e8d4cdf13
The native-ack path is fire-and-forget after the Part 9 Acknowledge returns, so a Galaxy ack refused for lack of a credential (#749), or rejected by MXAccess, left the client with Good and the condition showing Acknowledged while Galaxy was never acked. Both options from the issue, layered: 1. Synchronous refusal for what the driver can decide without I/O. New optional IAlarmAcknowledgePreflight (Core.Abstractions); GalaxyDriver refuses an anonymous session when the driver has no AlarmAck fallback account. DriverHostActor publishes a condition NodeId -> owning-driver snapshot into NativeAlarmAckPreflightView (lock-free, volatile); the node manager's new NativeAlarmAckPreflight seam, consulted after the authz and Primary gates, returns BadUserAccessDenied with the reason - nothing routed, applied or reported. A keyed session is not pre-checked (its identity lives in the Secrets store, which must not be read under the SDK lock). 2. Revert for everything else. RouteAlarmAck carries the condition NodeId; DriverInstanceActor replies AlarmAckFailed on a refusal, a backend failure, a driver without an alarm source, or an ack dropped while connecting / reconnecting. DriverHostActor re-projects the condition unacknowledged via NativeAlarmProjector.TryRevertAcknowledge, but only while the local ack is still pending - any driver transition projected since then wins. A disabled or unspawned driver is not reverted (unchanged). GatewayGalaxyAlarmAcknowledger now throws DriverOperationRefusedException on a non-zero native hresult (wrong password, no ack permission) instead of only warning, so that case reverts too; as a refusal it neither retries nor trips the breaker. Tests (each fails with its fix mutated out): projector revert/supersede, DriverHostActor refused + failed ack -> unacked AlarmStateUpdate (successful ack control), preflight view snapshot, node-manager preflight unit test, over-the-wire ConditionMethodEventTests (BadUserAccessDenied, no route, no event; positive control), Galaxy preflight + hresult refusal. Docs: Galaxy.md, AlarmTracking.md, ScriptedAlarms.md, .claude/rules/alarms.md. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
466 lines
21 KiB
C#
466 lines
21 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.MxGateway.Client;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
|
|
|
/// <summary>
|
|
/// lmxopcua#749 — operator-authenticated Galaxy ack (mxaccessgw 488649c+): the request carries a real
|
|
/// Galaxy account + password resolved from the API key's identity or the driver's fallback account,
|
|
/// refuses with neither, and never lets the password reach a log line or exception text.
|
|
/// </summary>
|
|
public sealed class GalaxyAckCredentialTests
|
|
{
|
|
private const string KeyId = "0a1b2c3d4e5f6071";
|
|
private const string KeyPassword = "key-pw-S3cr3t!";
|
|
private const string FallbackPassword = "fallback-pw-S3cr3t!";
|
|
|
|
// ------------------------------------------------------------------ request mapping
|
|
|
|
[Fact]
|
|
public void BuildRequest_carries_user_domain_and_password()
|
|
{
|
|
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
|
|
|
|
var request = GatewayGalaxyAlarmAcknowledger.BuildRequest("Tank01.Level.HiHi", "investigating", credential);
|
|
|
|
request.AlarmFullReference.ShouldBe("Tank01.Level.HiHi");
|
|
request.Comment.ShouldBe("investigating");
|
|
request.OperatorUser.ShouldBe("alice");
|
|
request.OperatorDomain.ShouldBe("CORP");
|
|
request.OperatorPassword.ShouldBe(KeyPassword);
|
|
request.ClientCorrelationId.ShouldNotBeNullOrEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildRequest_allows_empty_password_but_never_empty_user()
|
|
{
|
|
// Galaxy accounts can have empty passwords (gateway contract) — the field is sent empty.
|
|
var request = GatewayGalaxyAlarmAcknowledger.BuildRequest(
|
|
"A.B", "c", new GalaxyAckCredential("bob", null, null, GalaxyAckCredentialSource.Fallback));
|
|
request.OperatorPassword.ShouldBe(string.Empty);
|
|
request.OperatorDomain.ShouldBe(string.Empty);
|
|
request.OperatorUser.ShouldBe("bob");
|
|
|
|
// The credential type itself cannot be built with an empty user — the gateway rejects one.
|
|
Should.Throw<ArgumentException>(() => new GalaxyAckCredential(" ", "D", "p", GalaxyAckCredentialSource.Fallback));
|
|
}
|
|
|
|
[Fact]
|
|
public void Credential_and_resolution_ToString_omit_the_password()
|
|
{
|
|
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
|
|
credential.ToString().ShouldNotContain(KeyPassword);
|
|
credential.ToString().ShouldContain("CORP\\alice");
|
|
GalaxyAckCredentialResolution.Resolved(credential).ToString().ShouldNotContain(KeyPassword);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ reply handling / redaction
|
|
|
|
[Fact]
|
|
public void EvaluateReply_protocol_failure_throws_with_password_scrubbed()
|
|
{
|
|
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
|
|
var reply = new AcknowledgeAlarmReply
|
|
{
|
|
ProtocolStatus = new ProtocolStatus
|
|
{
|
|
Code = ProtocolStatusCode.InvalidRequest,
|
|
// Pathological gateway echo — the scrub is defence in depth over the gateway's contract.
|
|
Message = $"auth failed for alice/{KeyPassword}",
|
|
},
|
|
};
|
|
var logger = new CapturingLogger();
|
|
|
|
var ex = Should.Throw<InvalidOperationException>(
|
|
() => GatewayGalaxyAlarmAcknowledger.EvaluateReply(reply, "A.B", credential, logger));
|
|
|
|
ex.ToString().ShouldNotContain(KeyPassword);
|
|
ex.Message.ShouldContain(GatewayGalaxyAlarmAcknowledger.Redacted);
|
|
ex.Message.ShouldContain("CORP\\alice");
|
|
logger.AllText.ShouldNotContain(KeyPassword);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A non-zero native code (MXAccess rejected the ack — e.g. a wrong password) warns AND throws a
|
|
/// <see cref="DriverOperationRefusedException"/> (lmxopcua#760) so the host reverts the OPC UA condition
|
|
/// instead of leaving it acknowledged; neither the log nor the exception carries the password.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EvaluateReply_native_failure_warns_and_refuses_without_password()
|
|
{
|
|
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
|
|
var reply = new AcknowledgeAlarmReply
|
|
{
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
Hresult = unchecked((int)0x80070005),
|
|
};
|
|
var logger = new CapturingLogger();
|
|
|
|
var ex = Should.Throw<DriverOperationRefusedException>(
|
|
() => GatewayGalaxyAlarmAcknowledger.EvaluateReply(reply, "A.B", credential, logger));
|
|
|
|
ex.Message.ShouldContain("0x80070005");
|
|
ex.Message.ShouldContain("CORP\\alice");
|
|
ex.ToString().ShouldNotContain(KeyPassword);
|
|
logger.Entries.ShouldHaveSingleItem().Level.ShouldBe(LogLevel.Warning);
|
|
logger.AllText.ShouldContain("CORP\\alice");
|
|
logger.AllText.ShouldNotContain(KeyPassword);
|
|
}
|
|
|
|
/// <summary>A zero native code is success: nothing logged, nothing thrown.</summary>
|
|
[Fact]
|
|
public void EvaluateReply_success_is_silent()
|
|
{
|
|
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
|
|
var reply = new AcknowledgeAlarmReply
|
|
{
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
Hresult = 0,
|
|
};
|
|
var logger = new CapturingLogger();
|
|
|
|
GatewayGalaxyAlarmAcknowledger.EvaluateReply(reply, "A.B", credential, logger);
|
|
|
|
logger.Entries.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Acknowledger_transport_failure_exception_text_has_no_password()
|
|
{
|
|
var client = MxGatewayClient.Create(new MxGatewayClientOptions
|
|
{
|
|
Endpoint = new Uri("http://127.0.0.1:1", UriKind.Absolute),
|
|
ApiKey = "test-key",
|
|
UseTls = false,
|
|
ConnectTimeout = TimeSpan.FromMilliseconds(200),
|
|
DefaultCallTimeout = TimeSpan.FromMilliseconds(200),
|
|
});
|
|
await using (client)
|
|
{
|
|
var logger = new CapturingLogger();
|
|
var acknowledger = new GatewayGalaxyAlarmAcknowledger(client, logger);
|
|
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
|
|
|
|
var ex = await Record.ExceptionAsync(
|
|
() => acknowledger.AcknowledgeAsync("A.B", "c", credential, CancellationToken.None));
|
|
|
|
ex.ShouldNotBeNull();
|
|
ex.ToString().ShouldNotContain(KeyPassword);
|
|
logger.AllText.ShouldNotContain(KeyPassword);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Scrub_replaces_every_occurrence_and_ignores_empty_secret()
|
|
{
|
|
GatewayGalaxyAlarmAcknowledger.Scrub("a pw b pw", "pw").ShouldBe("a *** b ***");
|
|
GatewayGalaxyAlarmAcknowledger.Scrub("unchanged", string.Empty).ShouldBe("unchanged");
|
|
GatewayGalaxyAlarmAcknowledger.Scrub(null, "pw").ShouldBe(string.Empty);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ resolver paths
|
|
|
|
[Fact]
|
|
public async Task Resolver_key_mapped_identity_wins_over_fallback()
|
|
{
|
|
var secrets = new DictSecretResolver
|
|
{
|
|
[GalaxyAckIdentitySecrets.UserSecretName(KeyId)] = "CORP\\alice",
|
|
[GalaxyAckIdentitySecrets.PasswordSecretName(KeyId)] = KeyPassword,
|
|
["otopcua/galaxy/ack-fallback"] = FallbackPassword,
|
|
};
|
|
var logger = new CapturingLogger();
|
|
var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), secrets, logger);
|
|
|
|
var result = await resolver.ResolveAsync(KeyId, CancellationToken.None);
|
|
|
|
var credential = result.Credential.ShouldNotBeNull();
|
|
credential.Source.ShouldBe(GalaxyAckCredentialSource.ApiKey);
|
|
credential.User.ShouldBe("alice");
|
|
credential.Domain.ShouldBe("CORP");
|
|
credential.Password.ShouldBe(KeyPassword);
|
|
logger.AllText.ShouldNotContain(KeyPassword);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Resolver_key_without_identity_uses_fallback()
|
|
{
|
|
var secrets = new DictSecretResolver { ["otopcua/galaxy/ack-fallback"] = FallbackPassword };
|
|
var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), secrets, new CapturingLogger());
|
|
|
|
var credential = (await resolver.ResolveAsync(KeyId, CancellationToken.None)).Credential.ShouldNotBeNull();
|
|
|
|
credential.Source.ShouldBe(GalaxyAckCredentialSource.Fallback);
|
|
credential.User.ShouldBe("ack-svc");
|
|
credential.Domain.ShouldBe("PLANT");
|
|
credential.Password.ShouldBe(FallbackPassword);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Resolver_anonymous_uses_fallback()
|
|
{
|
|
var secrets = new DictSecretResolver { ["otopcua/galaxy/ack-fallback"] = FallbackPassword };
|
|
var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), secrets, new CapturingLogger());
|
|
|
|
var result = await resolver.ResolveAsync(apiKeyId: null, CancellationToken.None);
|
|
|
|
result.Credential.ShouldNotBeNull().Source.ShouldBe(GalaxyAckCredentialSource.Fallback);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData(KeyId)]
|
|
public async Task Resolver_refuses_with_no_identity_and_no_fallback(string? apiKeyId)
|
|
{
|
|
var resolver = new GalaxyAckCredentialResolver(
|
|
"drv-x", new GalaxyAlarmAckOptions(), new DictSecretResolver(), new CapturingLogger());
|
|
|
|
var result = await resolver.ResolveAsync(apiKeyId, CancellationToken.None);
|
|
|
|
result.Credential.ShouldBeNull();
|
|
result.RefusalReason.ShouldNotBeNull();
|
|
result.RefusalReason.ShouldContain("drv-x");
|
|
result.RefusalReason.ShouldContain("FallbackUser");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Resolver_refuses_key_with_user_but_no_password_even_when_fallback_exists()
|
|
{
|
|
// A key the admin explicitly mapped must not silently ack as the shared fallback account.
|
|
var secrets = new DictSecretResolver
|
|
{
|
|
[GalaxyAckIdentitySecrets.UserSecretName(KeyId)] = "alice",
|
|
["otopcua/galaxy/ack-fallback"] = FallbackPassword,
|
|
};
|
|
var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), secrets, new CapturingLogger());
|
|
|
|
var result = await resolver.ResolveAsync(KeyId, CancellationToken.None);
|
|
|
|
result.Credential.ShouldBeNull();
|
|
result.RefusalReason.ShouldNotBeNull().ShouldContain("no password secret");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Resolver_key_with_empty_password_is_allowed()
|
|
{
|
|
var secrets = new DictSecretResolver
|
|
{
|
|
[GalaxyAckIdentitySecrets.UserSecretName(KeyId)] = "alice",
|
|
[GalaxyAckIdentitySecrets.PasswordSecretName(KeyId)] = string.Empty,
|
|
};
|
|
var resolver = new GalaxyAckCredentialResolver("drv", new GalaxyAlarmAckOptions(), secrets, new CapturingLogger());
|
|
|
|
var credential = (await resolver.ResolveAsync(KeyId, CancellationToken.None)).Credential.ShouldNotBeNull();
|
|
credential.Password.ShouldBe(string.Empty);
|
|
credential.Domain.ShouldBe(string.Empty);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Resolver_refuses_literal_fallback_password_without_echoing_it()
|
|
{
|
|
var logger = new CapturingLogger();
|
|
var resolver = new GalaxyAckCredentialResolver(
|
|
"drv", new GalaxyAlarmAckOptions("ack-svc", null, FallbackPassword), new DictSecretResolver(), logger);
|
|
|
|
var result = await resolver.ResolveAsync(null, CancellationToken.None);
|
|
|
|
result.Credential.ShouldBeNull();
|
|
result.RefusalReason.ShouldNotBeNull().ShouldNotContain(FallbackPassword);
|
|
logger.AllText.ShouldNotContain(FallbackPassword);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Resolver_refuses_when_fallback_secret_absent()
|
|
{
|
|
var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), new DictSecretResolver(), new CapturingLogger());
|
|
|
|
var result = await resolver.ResolveAsync(null, CancellationToken.None);
|
|
|
|
result.Credential.ShouldBeNull();
|
|
result.RefusalReason.ShouldNotBeNull().ShouldContain("secret:otopcua/galaxy/ack-fallback");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("${secret:otopcua/galaxy/ack-fallback}")]
|
|
[InlineData("secret:otopcua/galaxy/ack-fallback")]
|
|
public async Task Resolver_accepts_both_secret_reference_spellings(string reference)
|
|
{
|
|
var secrets = new DictSecretResolver { ["otopcua/galaxy/ack-fallback"] = FallbackPassword };
|
|
var resolver = new GalaxyAckCredentialResolver(
|
|
"drv", new GalaxyAlarmAckOptions("ack-svc", null, reference), secrets, new CapturingLogger());
|
|
|
|
(await resolver.ResolveAsync(null, CancellationToken.None)).Credential.ShouldNotBeNull()
|
|
.Password.ShouldBe(FallbackPassword);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Resolver_secret_store_fault_is_a_refusal_not_a_throw()
|
|
{
|
|
var logger = new CapturingLogger();
|
|
var resolver = new GalaxyAckCredentialResolver(
|
|
"drv", WithFallback(), new ThrowingSecretResolver(KeyPassword), logger);
|
|
|
|
var result = await resolver.ResolveAsync(KeyId, CancellationToken.None);
|
|
|
|
result.Credential.ShouldBeNull();
|
|
result.RefusalReason.ShouldNotBeNull().ShouldNotContain(KeyPassword);
|
|
logger.AllText.ShouldNotContain(KeyPassword);
|
|
}
|
|
|
|
[Fact]
|
|
public void Identity_secret_names_and_parsing()
|
|
{
|
|
GalaxyAckIdentitySecrets.UserSecretName("0A1B").ShouldBe("otopcua/apikeys/0a1b/galaxy-ack-user");
|
|
GalaxyAckIdentitySecrets.PasswordSecretName("0a1b").ShouldBe("otopcua/apikeys/0a1b/galaxy-ack-password");
|
|
Should.Throw<ArgumentException>(() => GalaxyAckIdentitySecrets.UserSecretName("../x"));
|
|
GalaxyAckIdentitySecrets.ParseIdentity("CORP\\alice").ShouldBe(("alice", "CORP"));
|
|
GalaxyAckIdentitySecrets.ParseIdentity("alice").ShouldBe(("alice", string.Empty));
|
|
GalaxyAckIdentitySecrets.FormatIdentity("alice", "CORP").ShouldBe("CORP\\alice");
|
|
GalaxyAckIdentitySecrets.FormatIdentity("alice", " ").ShouldBe("alice");
|
|
}
|
|
|
|
// ------------------------------------------------------------------ driver paths
|
|
|
|
[Fact]
|
|
public async Task Driver_key_mapped_path_sends_the_keys_account()
|
|
{
|
|
var ack = new CredentialRecordingAcknowledger();
|
|
var secrets = new DictSecretResolver
|
|
{
|
|
[GalaxyAckIdentitySecrets.UserSecretName(KeyId)] = "CORP\\alice",
|
|
[GalaxyAckIdentitySecrets.PasswordSecretName(KeyId)] = KeyPassword,
|
|
};
|
|
var logger = new CapturingLogger();
|
|
using var driver = NewDriver(Opts(), ack, secrets, logger);
|
|
|
|
await driver.AcknowledgeAsync(
|
|
[new AlarmAcknowledgeRequest("src", "Tank01.Level.HiHi", "cmt", "mes-line1", KeyId)], CancellationToken.None);
|
|
|
|
var call = ack.Calls.ShouldHaveSingleItem();
|
|
call.Credential.User.ShouldBe("alice");
|
|
call.Credential.Password.ShouldBe(KeyPassword);
|
|
call.Comment.ShouldBe($"cmt [OPC UA: mes-line1, key {KeyId}]");
|
|
logger.AllText.ShouldNotContain(KeyPassword);
|
|
logger.AllText.ShouldContain("mes-line1");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Driver_fallback_path_sends_the_fallback_account()
|
|
{
|
|
var ack = new CredentialRecordingAcknowledger();
|
|
var secrets = new DictSecretResolver { ["otopcua/galaxy/ack-fallback"] = FallbackPassword };
|
|
var logger = new CapturingLogger();
|
|
using var driver = NewDriver(Opts() with { AlarmAck = WithFallback() }, ack, secrets, logger);
|
|
|
|
await driver.AcknowledgeAsync(
|
|
[new AlarmAcknowledgeRequest("src", "Tank01.Level.HiHi", null, OperatorUser: "anonymous")], CancellationToken.None);
|
|
|
|
var call = ack.Calls.ShouldHaveSingleItem();
|
|
call.Credential.Source.ShouldBe(GalaxyAckCredentialSource.Fallback);
|
|
call.Credential.User.ShouldBe("ack-svc");
|
|
call.Credential.Password.ShouldBe(FallbackPassword);
|
|
call.Comment.ShouldBe("[OPC UA: anonymous]");
|
|
logger.AllText.ShouldNotContain(FallbackPassword);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Driver_refused_path_sends_nothing_and_throws_refusal()
|
|
{
|
|
var ack = new CredentialRecordingAcknowledger();
|
|
var logger = new CapturingLogger();
|
|
using var driver = NewDriver(Opts(), ack, new DictSecretResolver(), logger);
|
|
|
|
var ex = await Should.ThrowAsync<DriverOperationRefusedException>(() => driver.AcknowledgeAsync(
|
|
[new AlarmAcknowledgeRequest("src", "Tank01.Level.HiHi", "cmt", "mes-line1", KeyId)], CancellationToken.None));
|
|
|
|
ack.Calls.ShouldBeEmpty(); // never reaches the gateway — and so never with an empty operator_user
|
|
ex.Message.ShouldContain("Tank01.Level.HiHi");
|
|
logger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Text.Contains("refused"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// lmxopcua#760 — the synchronous pre-check refuses exactly the case decidable without I/O: an anonymous
|
|
/// session on a driver with no fallback account. A keyed session is let through (its identity lives in the
|
|
/// Secrets store), and a fallback account makes every session acceptable.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Preflight_refuses_only_an_anonymous_session_without_a_fallback()
|
|
{
|
|
using var noFallback = NewDriver(Opts(), new CredentialRecordingAcknowledger(), new DictSecretResolver(), new CapturingLogger());
|
|
var refusal = noFallback.GetAcknowledgeRefusal(apiKeyId: null);
|
|
refusal.ShouldNotBeNull();
|
|
refusal.ShouldContain("FallbackUser");
|
|
refusal.ShouldContain("drv");
|
|
noFallback.GetAcknowledgeRefusal(apiKeyId: " ").ShouldNotBeNull("a blank key id is no key");
|
|
noFallback.GetAcknowledgeRefusal(KeyId).ShouldBeNull("a keyed session may have an identity in the Secrets store");
|
|
|
|
using var withFallback = NewDriver(
|
|
Opts() with { AlarmAck = WithFallback() }, new CredentialRecordingAcknowledger(), new DictSecretResolver(), new CapturingLogger());
|
|
withFallback.GetAcknowledgeRefusal(apiKeyId: null).ShouldBeNull();
|
|
withFallback.GetAcknowledgeRefusal(KeyId).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Annotate_comment_forms()
|
|
{
|
|
GalaxyDriver.AnnotateAckComment("x", "p", "k").ShouldBe("x [OPC UA: p, key k]");
|
|
GalaxyDriver.AnnotateAckComment(" ", null, null).ShouldBe("[OPC UA: anonymous]");
|
|
GalaxyDriver.AnnotateAckComment(null, "p", null).ShouldBe("[OPC UA: p]");
|
|
}
|
|
|
|
// ------------------------------------------------------------------ helpers
|
|
|
|
private static GalaxyAlarmAckOptions WithFallback() =>
|
|
new("ack-svc", "PLANT", "secret:otopcua/galaxy/ack-fallback");
|
|
|
|
private static GalaxyDriverOptions Opts() => new(
|
|
new GalaxyGatewayOptions("https://mxgw.test:5001", "dev:key"),
|
|
new GalaxyMxAccessOptions("OtOpcUa-A"),
|
|
new GalaxyRepositoryOptions(),
|
|
new GalaxyReconnectOptions());
|
|
|
|
private static GalaxyDriver NewDriver(
|
|
GalaxyDriverOptions options, IGalaxyAlarmAcknowledger ack, ISecretResolver secrets, ILogger logger) =>
|
|
new("drv", options, hierarchySource: null, alarmAcknowledger: ack,
|
|
logger: new TypedLogger<GalaxyDriver>(logger), secretResolver: secrets);
|
|
|
|
private sealed class TypedLogger<T>(ILogger inner) : ILogger<T>
|
|
{
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => inner.BeginScope(state);
|
|
public bool IsEnabled(LogLevel logLevel) => inner.IsEnabled(logLevel);
|
|
public void Log<TState>(LogLevel l, EventId id, TState s, Exception? e, Func<TState, Exception?, string> f)
|
|
=> inner.Log(l, id, s, e, f);
|
|
}
|
|
|
|
private sealed class CredentialRecordingAcknowledger : IGalaxyAlarmAcknowledger
|
|
{
|
|
public List<(string AlarmRef, string Comment, GalaxyAckCredential Credential)> Calls { get; } = [];
|
|
|
|
public Task AcknowledgeAsync(
|
|
string alarmFullReference, string comment, GalaxyAckCredential credential, CancellationToken cancellationToken)
|
|
{
|
|
Calls.Add((alarmFullReference, comment, credential));
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class DictSecretResolver : Dictionary<string, string>, ISecretResolver
|
|
{
|
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
|
Task.FromResult(TryGetValue(name.Value, out var v) ? v : null);
|
|
}
|
|
|
|
private sealed class ThrowingSecretResolver(string leak) : ISecretResolver
|
|
{
|
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
|
throw new InvalidOperationException($"store exploded near {leak}");
|
|
}
|
|
}
|