d68dff0946
A driver named 'drv' (env form DRV) matched every sibling's id-form variable env:<PREFIX>DRV_<12HEX>_... as 'another instance', breaking the recommended id convention fleet-wide - including for a name that pre-dates the rule. ReferenceNames is split into ReferenceNamesId / ReferenceNamesName; a reference whose instance part is id-shaped (secret: DRV-<12 hex>, env: DRV_<12 HEX>[_...]) is matched by id only. DescribeNameChangeProblemsAsync also refuses new names whose env form is DRV or starts with an id form (IsReservedInstanceName) at create, /raw rename and config-modal update. Co-Authored-By: Claude <noreply@anthropic.com>
168 lines
7.9 KiB
C#
168 lines
7.9 KiB
C#
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();
|
|
}
|
|
}
|