This commit is contained in:
+29
@@ -50,6 +50,35 @@ public sealed class DraftValidatorCredentialInstanceAndPathTests
|
||||
[new Device { DeviceId = "dev-b", DriverInstanceId = "DRV-b", Name = "d", DeviceConfig = """{"Password":"secret:opcuaclient/victim/pw"}""" }])
|
||||
.ShouldHaveSingleItem().Context.ShouldBe("dev-b");
|
||||
|
||||
/// <summary>lmxopcua#849: the env: form carries the same per-instance rule, under the same code.</summary>
|
||||
[Fact]
|
||||
public void A_galaxy_env_reference_naming_another_instance_is_refused()
|
||||
{
|
||||
var e = Validate(
|
||||
Drv("DRV-victim", "Victim", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:OTOPCUA_GALAXY_VICTIM_KEY"}}"""),
|
||||
Drv("DRV-evil", "Evil", "GalaxyMxGateway", """{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"env:GALAXY_MXGW_DRV_VICTIM_KEY"}}"""))
|
||||
.ShouldHaveSingleItem();
|
||||
e.Code.ShouldBe(CredentialReferenceDeployChecks.OtherInstanceCode);
|
||||
e.Context.ShouldBe("DRV-evil");
|
||||
e.Message.ShouldContain("lmxopcua#849");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shared_and_own_env_variables_pass() =>
|
||||
Validate(
|
||||
Drv("DRV-a", "A", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:OTOPCUA_GALAXY_DRV_A_KEY"}}"""),
|
||||
Drv("DRV-b", "B", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"}}"""),
|
||||
Drv("DRV-c", "C", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"}}"""))
|
||||
.ShouldBeEmpty();
|
||||
|
||||
/// <summary>#849 review: a pre-existing driver named 'drv' must not fail the id-form variables of its siblings.</summary>
|
||||
[Fact]
|
||||
public void A_driver_named_drv_does_not_break_id_form_env_variables() =>
|
||||
Validate(
|
||||
Drv("DRV-0123456789ab", "A", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY"}}"""),
|
||||
Drv("DRV-fedcba987654", "drv", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:OTOPCUA_GALAXY_DRV_FEDCBA987654_KEY"}}"""))
|
||||
.ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Shared_and_own_keys_pass() =>
|
||||
Validate(
|
||||
|
||||
+133
-2
@@ -44,7 +44,10 @@ public sealed class CredentialInstanceScopeTests
|
||||
[InlineData("secret:galaxy/gw/apikey")] // a shared name no instance uses
|
||||
[InlineData("secret:galaxy/apikey")]
|
||||
[InlineData("secret:galaxy/UpstreamC/apikey")] // another TYPE's instance — not this namespace's owner
|
||||
[InlineData("env:OTOPCUA_GALAXY_PLANTB_KEY")] // env carries no instance convention
|
||||
[InlineData("env:OTOPCUA_GALAXY_PLANTA_KEY")] // its own name in env form (lmxopcua#849)
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_AAA_KEY")] // its own id in env form
|
||||
[InlineData("env:GALAXY_MXGW_API_KEY")] // a shared variable that names no instance
|
||||
[InlineData("env:OTOPCUA_GALAXY_PLANTBX_KEY")] // 'PLANTB' only at a '_' boundary
|
||||
[InlineData("dev:k")]
|
||||
[InlineData("")]
|
||||
public void Own_shared_and_non_secret_references_pass(string reference) =>
|
||||
@@ -98,7 +101,7 @@ public sealed class CredentialInstanceScopeTests
|
||||
public void HasInstanceSegments_gates_the_ConfigDb_read()
|
||||
{
|
||||
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, Galaxy("secret:galaxy/x/apikey")).ShouldBeTrue();
|
||||
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_X")).ShouldBeFalse();
|
||||
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_X")).ShouldBeTrue(); // #849
|
||||
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, Galaxy("env:ConnectionStrings__ConfigDb")).ShouldBeFalse();
|
||||
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Modbus, """{"Password":"secret:galaxy/x"}""").ShouldBeFalse();
|
||||
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, "not json").ShouldBeFalse();
|
||||
@@ -113,4 +116,132 @@ public sealed class CredentialInstanceScopeTests
|
||||
problems.ShouldContain(p => p.Contains("lmxopcua#815"));
|
||||
problems.ShouldContain(p => p.Contains("lmxopcua#835"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// lmxopcua#849 — env: references follow the same per-instance rule (env:<PREFIX><INSTANCE>_…)
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("env:OTOPCUA_GALAXY_PLANTB_KEY")] // another instance's name, upper-cased
|
||||
[InlineData("env:OTOPCUA_GALAXY_PLANTB")] // the part alone
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_BBB_KEY")] // another instance's id, '-' as '_'
|
||||
[InlineData("env:GALAXY_MXGW_PLANTB_API_KEY")] // the legacy prefix too
|
||||
public void An_env_reference_naming_another_instance_is_refused(string reference)
|
||||
{
|
||||
var problem = DriverCredentialPolicy
|
||||
.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy(reference), "DRV-aaa", Instances)
|
||||
.ShouldHaveSingleItem();
|
||||
problem.ShouldContain("lmxopcua#849");
|
||||
problem.ShouldContain("DRV-bbb");
|
||||
problem.ShouldContain("Gateway.ApiKeySecretRef");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_env_match_against_another_instance_wins_over_the_owners()
|
||||
{
|
||||
// Owner "Line 1" (LINE_1) and another instance "Line" (LINE): OTOPCUA_GALAXY_LINE_1_KEY names both.
|
||||
IEnumerable<DriverInstanceIdentity> lines =
|
||||
[new("DRV-1", "Line 1", DriverTypeNames.Galaxy), new("DRV-2", "Line", DriverTypeNames.Galaxy)];
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_LINE_1_KEY"), "DRV-1", lines)
|
||||
.ShouldHaveSingleItem();
|
||||
// …while the owner's id-scoped variable names only the owner.
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_DRV_1_KEY"), "DRV-1", lines)
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpcUaClient_and_Heidenhain_env_forms_have_the_rule()
|
||||
{
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(
|
||||
DriverTypeNames.OpcUaClient, """{"Password":"env:OTOPCUA_OPCUACLIENT_UPSTREAMD_PW"}""", "DRV-ccc", Instances)
|
||||
.ShouldHaveSingleItem().ShouldContain("lmxopcua#849");
|
||||
IEnumerable<DriverInstanceIdentity> hh = [new("H1", "line1", DriverTypeNames.Heidenhain), new("H2", "line2", DriverTypeNames.Heidenhain)];
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(
|
||||
DriverTypeNames.Heidenhain, """{"Access":{"DncPassword":"env:OTOPCUA_HEIDENHAIN_LINE2_DNC"}}""", "H1", hh)
|
||||
.ShouldHaveSingleItem();
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(
|
||||
DriverTypeNames.Heidenhain, """{"Access":{"DncPassword":"env:OTOPCUA_HEIDENHAIN_DNC_PASSWORD"}}""", "H1", hh)
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DRV-1a2b", "DRV_1A2B")]
|
||||
[InlineData("Line 1", "LINE_1")]
|
||||
[InlineData("plant.b", "PLANT_B")]
|
||||
[InlineData("---", "")]
|
||||
[InlineData("", "")]
|
||||
public void Identifiers_normalise_to_environment_form(string identifier, string expected) =>
|
||||
DriverCredentialPolicy.NormalizeEnvironmentIdentifier(identifier).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Instance_references_are_collected_for_both_kinds()
|
||||
{
|
||||
var refs = DriverCredentialPolicy.CollectInstanceReferences(DriverTypeNames.Galaxy,
|
||||
"""{"Gateway":{"ApiKeySecretRef":"secret:galaxy/gw/k"},"AlarmAck":{"FallbackPasswordSecretRef":"env:OTOPCUA_GALAXY_ACK_PW"}}""");
|
||||
refs.Count.ShouldBe(2);
|
||||
refs[0].ShouldBe(new InstanceReference("Gateway.ApiKeySecretRef", CredentialReferenceKind.Secret, "gw"));
|
||||
refs[1].ShouldBe(new InstanceReference("AlarmAck.FallbackPasswordSecretRef", CredentialReferenceKind.Environment, "ACK_PW"));
|
||||
DriverCredentialPolicy.ReferenceNamesName(refs[1], "ack").ShouldBeTrue();
|
||||
DriverCredentialPolicy.ReferenceNamesName(refs[1], "ac").ShouldBeFalse();
|
||||
DriverCredentialPolicy.ReferenceNamesName(refs[0], "GW").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// #849 review — a driver NAMED 'drv' (or id-shaped) must not shadow the id convention
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
private const string RealId = "DRV-0123456789ab"; // the RawTreeService.NewId shape
|
||||
|
||||
private static readonly DriverInstanceIdentity[] WithDrvNamed =
|
||||
[
|
||||
new(RealId, "PlantA", DriverTypeNames.Galaxy),
|
||||
new("DRV-fedcba987654", "drv", DriverTypeNames.Galaxy), // a pre-existing driver named 'drv'
|
||||
new("DRV-111111111111", "DRV-0123456789AB", DriverTypeNames.Galaxy), // …or named like another's id
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY")]
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AB")]
|
||||
[InlineData("env:GALAXY_MXGW_DRV_0123456789AB_API_KEY")]
|
||||
[InlineData("secret:galaxy/DRV-0123456789ab/apikey")]
|
||||
public void An_id_form_reference_is_matched_by_id_only_so_a_pre_existing_drv_name_does_not_break_it(string reference) =>
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy(reference), RealId, WithDrvNamed)
|
||||
.ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void An_id_form_reference_still_names_the_other_instance_by_its_id() =>
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(
|
||||
DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY"), "DRV-fedcba987654", WithDrvNamed)
|
||||
.ShouldHaveSingleItem().ShouldContain(RealId);
|
||||
|
||||
[Fact]
|
||||
public void A_drv_name_still_matches_a_reference_that_is_not_id_shaped() =>
|
||||
// 'DRV_FOO' is not an id form, so the name 'drv' does claim it (the create/rename check refuses that name anyway).
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_DRV_FOO_KEY"), RealId, WithDrvNamed)
|
||||
.ShouldHaveSingleItem();
|
||||
|
||||
[Theory]
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY", true)]
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AB", true)]
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789ABC_KEY", false)] // 13 hex: not an id form
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AG_KEY", false)] // not hex
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_KEY", false)]
|
||||
[InlineData("secret:galaxy/DRV-0123456789ab/k", true)]
|
||||
[InlineData("secret:galaxy/drv-0123456789AB/k", true)]
|
||||
[InlineData("secret:galaxy/DRV-0123456789ab-x/k", false)]
|
||||
public void Id_shape_detection(string reference, bool expected) =>
|
||||
DriverCredentialPolicy.IsIdShaped(DriverCredentialPolicy.CollectInstanceReferences(DriverTypeNames.Galaxy, Galaxy(reference)).Single())
|
||||
.ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("drv", true)]
|
||||
[InlineData("DRV", true)]
|
||||
[InlineData("drv-", false)] // env form 'DRV_' — neither 'DRV' nor an id form
|
||||
[InlineData("DRV-0123456789ab", true)]
|
||||
[InlineData("drv 0123456789AB extra", true)]
|
||||
[InlineData("DRV-0123456789", false)]
|
||||
[InlineData("Driver", false)]
|
||||
[InlineData("PlantA", false)]
|
||||
public void Reserved_instance_names(string name, bool expected) =>
|
||||
DriverCredentialPolicy.IsReservedInstanceName(name).ShouldBe(expected);
|
||||
}
|
||||
|
||||
+35
@@ -72,6 +72,41 @@ public sealed class BrowserSessionCredentialScopeTests
|
||||
universal.Opens.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>lmxopcua#849: an env: reference named after ANOTHER instance never opens a browser either.</summary>
|
||||
[Theory]
|
||||
[InlineData("env:OTOPCUA_GALAXY_VICTIM_KEY")]
|
||||
[InlineData("env:GALAXY_MXGW_DRV_VICTIM_KEY")]
|
||||
public async Task An_env_reference_naming_another_instance_never_opens_a_browser(string reference)
|
||||
{
|
||||
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
|
||||
var svc = new BrowserSessionService(
|
||||
[bespoke], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, new RecordingUniversal(), null!, null!,
|
||||
SeededFactory());
|
||||
|
||||
var result = await svc.OpenAsync(DriverTypeNames.Galaxy,
|
||||
"""{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"REF"}}""".Replace("REF", reference), "DRV-evil",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull().ShouldContain("lmxopcua#849");
|
||||
bespoke.Opens.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a shared env variable that names no instance reaches the browser.</summary>
|
||||
[Fact]
|
||||
public async Task A_shared_env_variable_reaches_the_browser()
|
||||
{
|
||||
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
|
||||
var svc = new BrowserSessionService(
|
||||
[bespoke], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, new RecordingUniversal(), null!, null!,
|
||||
SeededFactory());
|
||||
|
||||
await svc.OpenAsync(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"}}""",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
bespoke.Opens.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: the owning driver's own id-scoped secret reaches the browser.</summary>
|
||||
[Fact]
|
||||
public async Task The_owners_own_secret_reaches_the_browser()
|
||||
|
||||
+42
@@ -52,4 +52,46 @@ public sealed class CredentialReferencePreflightInstanceAndPathTests
|
||||
f.Problem.ShouldContain("lmxopcua#836");
|
||||
f.Problem.ShouldNotContain("plant.pfx");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#850: the AuthType switch in one config and the path in the other — each config passes alone, the
|
||||
/// runtime-merged config does not. The deploy gate catches it; the banner must show it before a deploy does.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_path_split_from_its_certificate_switch_is_listed_from_the_merged_config()
|
||||
{
|
||||
var findings = CredentialReferencePreflight.Scan(
|
||||
[Drv("o", "Upstream", DriverTypeNames.OpcUaClient, """{"AuthType":"Anonymous","UserCertificatePath":"C:\\stolen\\host.pfx"}""")],
|
||||
[new Device { DeviceId = "dev-o", DriverInstanceId = "o", Name = "d", DeviceConfig = """{"AuthType":"Certificate"}""" }],
|
||||
[]);
|
||||
|
||||
var f = findings.ShouldHaveSingleItem();
|
||||
f.Source.ShouldBe(CredentialFindingSource.Draft);
|
||||
f.DriverInstanceId.ShouldBe("o");
|
||||
f.DeviceName.ShouldBeNull();
|
||||
f.Problem.ShouldContain("merged with its devices");
|
||||
f.Problem.ShouldContain("lmxopcua#836");
|
||||
f.Problem.ShouldNotContain("host.pfx");
|
||||
}
|
||||
|
||||
/// <summary>Control: a problem already reported on one config is not listed a second time from the merge.</summary>
|
||||
[Fact]
|
||||
public void A_path_already_reported_on_its_own_config_is_not_duplicated_by_the_merge() =>
|
||||
CredentialReferencePreflight.Scan(
|
||||
[Drv("o", "Upstream", DriverTypeNames.OpcUaClient, """{"AuthType":"Certificate","UserCertificatePath":"C:\\certs\\plant.pfx"}""")],
|
||||
[new Device { DeviceId = "dev-o", DriverInstanceId = "o", Name = "d", DeviceConfig = "{}" }],
|
||||
[])
|
||||
.ShouldHaveSingleItem().Problem.ShouldNotContain("merged");
|
||||
|
||||
/// <summary>lmxopcua#849: an env: reference naming another instance is listed like a secret: one.</summary>
|
||||
[Fact]
|
||||
public void A_cross_instance_env_reference_is_listed() =>
|
||||
CredentialReferencePreflight.Scan(
|
||||
[
|
||||
Drv("DRV-a", "A", DriverTypeNames.Galaxy, "{}"),
|
||||
Drv("DRV-b", "B", DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"env:OTOPCUA_GALAXY_DRV_A_KEY"}}"""),
|
||||
],
|
||||
[],
|
||||
[])
|
||||
.ShouldHaveSingleItem().Problem.ShouldContain("lmxopcua#849");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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#848: a driver's NAME is checked against the credential references stored on other drivers. Naming a driver
|
||||
/// after another same-type driver's (shared) secret segment or env part would make that reference name "another
|
||||
/// instance" and fail its next deploy; renaming a driver away from a name its own references are named after would
|
||||
/// free that name for every other driver's Test Connect / browse / save. Both are refused at create, at the /raw
|
||||
/// rename and at the config modal's update.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CredentialNameClaimSavePathTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static string Galaxy(string reference) =>
|
||||
"""{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"REF"}}""".Replace("REF", reference);
|
||||
|
||||
private static async Task<(RawTreeService Svc, string Db, string SharerId)> WithSharerAsync(string reference)
|
||||
{
|
||||
var name = RawTreeTestDb.SeedFresh();
|
||||
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
|
||||
var sharer = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "Sharer", DriverTypeNames.Galaxy, Galaxy(reference), Ct);
|
||||
sharer.Ok.ShouldBeTrue(sharer.Error);
|
||||
return (svc, name, sharer.CreatedId!);
|
||||
}
|
||||
|
||||
private static byte[] RowVersion(string db, string driverId)
|
||||
{
|
||||
using var ctx = RawTreeTestDb.CreateNamed(db);
|
||||
return ctx.DriverInstances.Single(d => d.DriverInstanceId == driverId).RowVersion;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("secret:galaxy/gw/very-private-name", "gw")]
|
||||
[InlineData("secret:galaxy/gw/very-private-name", "GW")] // case-insensitive, as the rule matches
|
||||
[InlineData("env:GALAXY_MXGW_API_KEY_VERY_PRIVATE", "api")] // #849 env form: API is a '_'-bounded prefix
|
||||
[InlineData("env:OTOPCUA_GALAXY_LINE_1_VERY_PRIVATE", "line-1")] // normalised LINE_1
|
||||
public async Task Creating_a_driver_named_after_another_drivers_reference_is_refused(string reference, string newName)
|
||||
{
|
||||
var (svc, db, sharerId) = await WithSharerAsync(reference);
|
||||
|
||||
var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, newName, DriverTypeNames.Galaxy, "{}", Ct);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error!.ShouldContain("lmxopcua#848");
|
||||
result.Error!.ShouldContain("Sharer");
|
||||
result.Error!.ShouldContain(sharerId);
|
||||
result.Error!.ShouldContain("Gateway.ApiKeySecretRef");
|
||||
result.Error!.ShouldNotContain("PRIVATE", Case.Insensitive); // never the rest of the reference
|
||||
using var check = RawTreeTestDb.CreateNamed(db);
|
||||
check.DriverInstances.ShouldNotContain(d => d.Name == newName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_device_level_reference_claims_the_name_too()
|
||||
{
|
||||
var (svc, db, sharerId) = await WithSharerAsync("secret:galaxy/sharer-own/k");
|
||||
using (var ctx = RawTreeTestDb.CreateNamed(db))
|
||||
{
|
||||
var device = ctx.Devices.Single(d => d.DriverInstanceId == sharerId);
|
||||
device.DeviceConfig = Galaxy("secret:galaxy/plant7/k");
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "Plant7", DriverTypeNames.Galaxy, "{}", Ct);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error!.ShouldContain("device 'Device1'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Another_driver_type_and_unrelated_names_are_not_claimed()
|
||||
{
|
||||
var (svc, _, _) = await WithSharerAsync("secret:galaxy/gw/k");
|
||||
|
||||
(await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "gw", DriverTypeNames.OpcUaClient, "{}", Ct)).Ok.ShouldBeTrue();
|
||||
(await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "gw2", DriverTypeNames.Galaxy, "{}", Ct)).Ok.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Renaming_onto_a_claimed_name_is_refused_on_both_rename_paths()
|
||||
{
|
||||
var (svc, db, _) = await WithSharerAsync("secret:galaxy/gw/k");
|
||||
var other = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "Other", DriverTypeNames.Galaxy, "{}", Ct);
|
||||
other.Ok.ShouldBeTrue(other.Error);
|
||||
|
||||
var renamed = await svc.RenameDriverAsync(other.CreatedId!, "gw", RowVersion(db, other.CreatedId!), Ct);
|
||||
renamed.Ok.ShouldBeFalse();
|
||||
renamed.Error!.ShouldContain("lmxopcua#848");
|
||||
|
||||
var updated = await svc.UpdateDriverAsync(other.CreatedId!, "gw", "{}", null, RowVersion(db, other.CreatedId!), Ct);
|
||||
updated.Ok.ShouldBeFalse();
|
||||
updated.Error!.ShouldContain("lmxopcua#848");
|
||||
|
||||
using var check = RawTreeTestDb.CreateNamed(db);
|
||||
check.DriverInstances.Single(d => d.DriverInstanceId == other.CreatedId).Name.ShouldBe("Other");
|
||||
}
|
||||
|
||||
/// <summary>Issue (b): renaming the victim away from the name its own secret lives under would free that name.</summary>
|
||||
[Theory]
|
||||
[InlineData("secret:galaxy/Sharer/apikey")]
|
||||
[InlineData("env:OTOPCUA_GALAXY_SHARER_KEY")]
|
||||
public async Task A_driver_whose_references_are_named_after_it_cannot_be_renamed_away(string reference)
|
||||
{
|
||||
var (svc, db, sharerId) = await WithSharerAsync(reference);
|
||||
|
||||
var renamed = await svc.RenameDriverAsync(sharerId, "Sharer-old", RowVersion(db, sharerId), Ct);
|
||||
renamed.Ok.ShouldBeFalse();
|
||||
renamed.Error!.ShouldContain("lmxopcua#848");
|
||||
renamed.Error!.ShouldContain(sharerId); // points at the id-scoped form
|
||||
|
||||
(await svc.UpdateDriverAsync(sharerId, "Sharer-old", Galaxy(reference), null, RowVersion(db, sharerId), Ct)).Ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Re_pointing_to_the_id_in_the_same_save_allows_the_rename()
|
||||
{
|
||||
var (svc, db, sharerId) = await WithSharerAsync("secret:galaxy/Sharer/apikey");
|
||||
|
||||
var updated = await svc.UpdateDriverAsync(
|
||||
sharerId, "Sharer-new", Galaxy($"secret:galaxy/{sharerId}/apikey"), null, RowVersion(db, sharerId), Ct);
|
||||
|
||||
updated.Ok.ShouldBeTrue(updated.Error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("sharer")] // case-only: the rule matches case-insensitively, so nothing is freed or claimed
|
||||
[InlineData("Renamed")]
|
||||
public async Task Renames_that_free_and_claim_nothing_pass(string newName)
|
||||
{
|
||||
var reference = newName == "sharer" ? "secret:galaxy/Sharer/apikey" : "secret:galaxy/shared-gw/apikey";
|
||||
var (svc, db, sharerId) = await WithSharerAsync(reference);
|
||||
|
||||
var renamed = await svc.RenameDriverAsync(sharerId, newName, RowVersion(db, sharerId), Ct);
|
||||
|
||||
renamed.Ok.ShouldBeTrue(renamed.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #849 review: a name in the system-generated id form ('drv', 'DRV-<12 hex>…') would shadow every driver's
|
||||
/// id-form references — refused at create, the /raw rename and the config modal, even with no reference stored.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("drv")]
|
||||
[InlineData("DRV")]
|
||||
[InlineData("DRV-0123456789ab")]
|
||||
[InlineData("drv-0123456789AB-line")]
|
||||
public async Task An_id_shaped_name_is_refused_on_every_name_path(string newName)
|
||||
{
|
||||
var (svc, db, sharerId) = await WithSharerAsync("env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY");
|
||||
|
||||
var create = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, newName, DriverTypeNames.Galaxy, "{}", Ct);
|
||||
create.Ok.ShouldBeFalse();
|
||||
create.Error!.ShouldContain("system-generated driver id");
|
||||
|
||||
(await svc.RenameDriverAsync(sharerId, newName, RowVersion(db, sharerId), Ct)).Ok.ShouldBeFalse();
|
||||
(await svc.UpdateDriverAsync(sharerId, newName, "{}", null, RowVersion(db, sharerId), Ct)).Ok.ShouldBeFalse();
|
||||
|
||||
// A type without credential rules is not affected.
|
||||
(await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, newName, DriverTypeNames.Modbus, "{}", Ct)).Ok.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// The SQL-Server-backed half of <see cref="CredentialNameClaimSavePathTests"/> (lmxopcua#848): the driver + device
|
||||
/// reads of <see cref="DriverInstanceIdentities.DescribeNameChangeProblemsAsync"/> must translate on the real provider,
|
||||
/// not only run as LINQ-to-Objects under EF InMemory (the #815 live gate's crash-looping boot came from exactly that gap).
|
||||
/// </summary>
|
||||
[Collection(nameof(AdminUiSqlServerCollection))]
|
||||
public sealed class CredentialNameClaimSqlServerTests(AdminUiSqlServerFixture sql)
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[SqlServerFact]
|
||||
public async Task Create_and_rename_are_checked_against_stored_driver_and_device_references()
|
||||
{
|
||||
// 8 hex chars: unique per run in the shared database, short enough for the id columns.
|
||||
var token = Guid.NewGuid().ToString("N")[..8];
|
||||
var clusterId = $"C-{token}";
|
||||
var sharerId = $"DRV-s{token}";
|
||||
await using (var db = await sql.Factory.CreateDbContextAsync(Ct))
|
||||
{
|
||||
db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = clusterId, Name = $"Cluster {token}", Enterprise = "zb", Site = "warsaw-west",
|
||||
NodeCount = 1, RedundancyMode = RedundancyMode.None, CreatedBy = "test",
|
||||
});
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = sharerId, ClusterId = clusterId, Name = $"Sharer{token}", DriverType = DriverTypeNames.Galaxy,
|
||||
DriverConfig = "{\"Gateway\":{\"ApiKeySecretRef\":\"secret:galaxy/gw" + token + "/k\"}}",
|
||||
});
|
||||
db.Devices.Add(new Device
|
||||
{
|
||||
DeviceId = $"DEV-s{token}", DriverInstanceId = sharerId, Name = "Device1",
|
||||
DeviceConfig = "{\"AlarmAck\":{\"FallbackPasswordSecretRef\":\"env:OTOPCUA_GALAXY_SHARER" + token.ToUpperInvariant() + "_ACK\"}}",
|
||||
});
|
||||
await db.SaveChangesAsync(Ct);
|
||||
}
|
||||
|
||||
var svc = new RawTreeService(sql.Factory);
|
||||
|
||||
// Claim (driver-level secret: reference).
|
||||
var create = await svc.CreateDriverAsync(clusterId, null, $"gw{token}", DriverTypeNames.Galaxy, "{}", Ct);
|
||||
create.Ok.ShouldBeFalse();
|
||||
create.Error!.ShouldContain("lmxopcua#848");
|
||||
|
||||
// Pin (device-level env: reference named after the sharer's own name).
|
||||
byte[] rv;
|
||||
await using (var db = await sql.Factory.CreateDbContextAsync(Ct))
|
||||
rv = await db.DriverInstances.AsNoTracking().Where(d => d.DriverInstanceId == sharerId).Select(d => d.RowVersion).SingleAsync(Ct);
|
||||
var rename = await svc.RenameDriverAsync(sharerId, $"Moved{token}", rv, Ct);
|
||||
rename.Ok.ShouldBeFalse();
|
||||
rename.Error!.ShouldContain("device 'Device1'");
|
||||
|
||||
// Positive control: an unclaimed name creates.
|
||||
(await svc.CreateDriverAsync(clusterId, null, $"free{token}", DriverTypeNames.Galaxy, "{}", Ct)).Ok.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
+38
@@ -76,6 +76,44 @@ public sealed class AdminOperationsActorCredentialScopeTests : ControlPlaneActor
|
||||
probe.Calls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>lmxopcua#849: an env: reference named after ANOTHER instance never reaches the probe either.</summary>
|
||||
[Theory]
|
||||
[InlineData("env:OTOPCUA_GALAXY_VICTIM_API_KEY")]
|
||||
[InlineData("env:GALAXY_MXGW_DRV_VICTIM_KEY")]
|
||||
public void An_env_reference_naming_another_instance_never_reaches_the_probe(string reference)
|
||||
{
|
||||
var factory = NewInMemoryDbFactory();
|
||||
SeedGalaxyDrivers(factory);
|
||||
var probe = new RecordingProbe(DriverTypeNames.Galaxy);
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(factory, CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
|
||||
|
||||
actor.Tell(new TestDriverConnect(
|
||||
DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"REF"}}""".Replace("REF", reference),
|
||||
5, Guid.NewGuid(), "DRV-evil"));
|
||||
|
||||
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
|
||||
reply.Ok.ShouldBeFalse();
|
||||
reply.Message.ShouldNotBeNull().ShouldContain("lmxopcua#849");
|
||||
probe.Calls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a shared env variable naming no instance reaches the probe.</summary>
|
||||
[Fact]
|
||||
public void A_shared_env_variable_reaches_the_probe()
|
||||
{
|
||||
var factory = NewInMemoryDbFactory();
|
||||
SeedGalaxyDrivers(factory);
|
||||
var probe = new RecordingProbe(DriverTypeNames.Galaxy);
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(factory, CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
|
||||
|
||||
actor.Tell(new TestDriverConnect(
|
||||
DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"}}""",
|
||||
5, Guid.NewGuid()));
|
||||
|
||||
ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken).Ok.ShouldBeTrue();
|
||||
probe.Calls.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: the owner's own secret (and a shared one) does reach the probe.</summary>
|
||||
[Theory]
|
||||
[InlineData("secret:galaxy/DRV-victim/apikey")]
|
||||
|
||||
Reference in New Issue
Block a user