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; /// /// 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. /// [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"); } /// Issue (b): renaming the victim away from the name its own secret lives under would free that name. [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); } /// /// #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. /// [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(); } }