diff --git a/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs b/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs index 523e4887d..1a8ff6257 100644 --- a/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs +++ b/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs @@ -433,32 +433,22 @@ public void StartAgent(IAgentApplicationConfiguration configuration, bool verbos var freshlyConstructed = (existingAgentInformation == null); var agentInformation = existingAgentInformation ?? new MTConnectAgentInformation(); - // Apply explicit AgentUuid config override (if set). This - // pins the Agent meta-device UUID across restarts without - // requiring agent.information.json to be present on disk, - // and takes precedence over any UUID previously stored in - // that file. See MTConnect v2.7 XSD UuidType ("for its - // entire life") vs Header.instanceId (per-boot). - if (!string.IsNullOrEmpty(configuration.AgentUuid)) - { - agentInformation.Uuid = configuration.AgentUuid; - } - - // When no operator config override and no persisted state file, - // derive a deterministic UUID v5 (RFC 4122 §4.3, DNS namespace, - // SHA-1) from ServiceName:port so the Agent meta-device satisfies - // UuidType's "for it's entire life" annotation across every restart - // in the ephemeral-container deployment path. Mirrors cppagent's - // name_generator prior art. Port is 0 (sentinel) because - // IAgentApplicationConfiguration does not surface a listener-port - // property; the seed is still unique per ServiceName. - if (freshlyConstructed && string.IsNullOrEmpty(configuration.AgentUuid)) - { - agentInformation.Uuid = DeterministicAgentUuid.Derive( - configuration.ServiceName, - System.Environment.MachineName, - port: 0); - } + // Resolve the Agent meta-device UUID via the shared three-path + // algorithm in AgentUuidResolver so this application and the + // test fixtures exercise the same code and cannot silently + // drift. Path 1 (validated operator override) wins, else Path 2 + // (validated persisted state), else Path 3 (deterministic + // UUID v5 derivation from ServiceName). Malformed input on + // either the override or the persisted path logs a warning + // and falls through — silently forwarding non-UUID content + // would break MTConnect Part 1's wire-XSD validation on every + // typed enum/decimal DataItem. + agentInformation.Uuid = AgentUuidResolver.Resolve( + operatorSuppliedUuid: configuration.AgentUuid, + persistedUuid: freshlyConstructed ? null : agentInformation.Uuid, + agentName: configuration.ServiceName, + hostname: System.Environment.MachineName, + warn: message => _applicationLogger?.Warn(message)); // Create Observation File Buffer if (configuration.Durable) diff --git a/libraries/MTConnect.NET-Common/Agents/AgentUuidResolver.cs b/libraries/MTConnect.NET-Common/Agents/AgentUuidResolver.cs new file mode 100644 index 000000000..2bb865452 --- /dev/null +++ b/libraries/MTConnect.NET-Common/Agents/AgentUuidResolver.cs @@ -0,0 +1,161 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; + +namespace MTConnect.Agents +{ + /// + /// Resolves the Agent meta-device UUID from the three canonical sources — + /// operator-supplied config override, persisted agent.information.json + /// state, and a deterministic UUID v5 derivation — with RFC 4122 validation + /// applied uniformly on both the override and the persisted paths. + /// + /// + /// Shared between MTConnectAgentApplication.StartAgent and the test + /// fixtures so the boot-time resolution is exercised by the same code in + /// both places; the tests cannot silently drift from production semantics. + /// + /// + /// + /// Resolution order: + /// + /// + /// + /// Path 1 — operator-supplied override. If + /// parses as an RFC 4122 UUID + /// (via ), the canonical + /// hyphenated form wins. Malformed input logs a warning via + /// and falls through to Path 2 / Path 3. + /// + /// + /// Path 2 — persisted state. If + /// parses as an RFC 4122 UUID, the + /// canonical form wins. Malformed persisted state (e.g. a pre-hardening + /// agent version wrote a non-UUID string, or the file was hand-edited) + /// logs a warning and falls through to Path 3 so a spec-conformant UUID + /// always reaches the wire. + /// + /// + /// Path 3 — deterministic derivation. + /// over + /// (agentName ?? hostname, hostname, port: 0). Port is 0 + /// because IAgentApplicationConfiguration does not surface a + /// listener-port property; the seed is still unique per agent name. + /// + /// + /// + /// + /// Spec rationale — MTConnect Part 1 types the uuid attribute as the + /// UUID DataType (RFC 4122). Silently forwarding a non-UUID string + /// (from any source) breaks XSD validation on every typed enum/decimal + /// DataItem and diverges from the cppagent reference implementation, which + /// rejects malformed input at ingress. + /// + /// + public static class AgentUuidResolver + { + /// + /// Maximum length of an AgentUuid value echoed to the warning + /// log. Valid UUIDs are 32–38 characters; anything longer is almost + /// certainly a paste-in-wrong-field mistake (e.g. an API key) whose + /// full value should not persist in log archives. + /// + private const int LogValueMaxLength = 64; + + /// + /// Resolves the Agent meta-device UUID per the three-path algorithm. + /// + /// + /// Raw value from AgentApplicationConfiguration.AgentUuid; may + /// be , empty, or malformed. + /// + /// + /// Raw value from MTConnectAgentInformation.Read().Uuid, or + /// when no agent.information.json exists + /// (freshly constructed lifecycle). May itself be malformed if a prior + /// agent boot wrote non-UUID content. + /// + /// + /// The logical agent name (typically configuration.ServiceName). + /// Passed verbatim to , + /// which falls back to when this is + /// or empty. + /// + /// + /// The machine host name (typically + /// ). Used by + /// as both the fallback + /// seed component and the deterministic derivation input. + /// + /// + /// Optional delegate invoked with a human-readable message when Path 1 + /// or Path 2 rejects malformed input. Kept as a plain + /// so MTConnect.NET-Common does not + /// take a hard dependency on any logging framework; the caller adapts + /// it to NLog, Serilog, or Microsoft.Extensions.Logging. Raw + /// values echoed via this delegate are pre-sanitised (CRLF stripped, + /// truncated to characters) to guard + /// against log-injection and secret leakage. + /// + /// + /// The canonical hyphenated RFC 4122 UUID string that the agent must + /// adopt for its meta-device. + /// + public static string Resolve( + string operatorSuppliedUuid, + string persistedUuid, + string agentName, + string hostname, + Action warn = null) + { + // Path 1 — validated operator override wins. + if (DeterministicAgentUuid.TryValidate(operatorSuppliedUuid, out var normalizedOverride)) + { + return normalizedOverride; + } + + // Path 1 rejected but operator supplied something → warn. + if (!string.IsNullOrEmpty(operatorSuppliedUuid)) + { + var fallbackKind = DeterministicAgentUuid.TryValidate(persistedUuid, out _) + ? "persisted" + : "derived"; + warn?.Invoke(string.Format( + "AgentUuid override '{0}' is not a valid RFC 4122 UUID; falling back to {1} UUID.", + SanitiseForLog(operatorSuppliedUuid), + fallbackKind)); + } + + // Path 2 — validated persisted state wins over derivation. + if (DeterministicAgentUuid.TryValidate(persistedUuid, out var normalizedPersisted)) + { + return normalizedPersisted; + } + + // Path 2 rejected but persisted state carried something → warn. + if (!string.IsNullOrEmpty(persistedUuid)) + { + warn?.Invoke( + "Persisted AgentUuid in agent.information.json is not a valid RFC 4122 UUID; falling back to derived UUID."); + } + + // Path 3 — deterministic derivation. + return DeterministicAgentUuid.Derive(agentName, hostname, port: 0); + } + + /// + /// Strips CR/LF (log-injection guard) and truncates to + /// characters (secret-leakage guard) + /// before echoing an operator-supplied value to the warning log. + /// + private static string SanitiseForLog(string value) + { + if (string.IsNullOrEmpty(value)) return value; + var stripped = value.Replace("\r", string.Empty).Replace("\n", string.Empty); + return stripped.Length > LogValueMaxLength + ? stripped.Substring(0, LogValueMaxLength) + "…" + : stripped; + } + } +} diff --git a/libraries/MTConnect.NET-Common/Agents/DeterministicAgentUuid.cs b/libraries/MTConnect.NET-Common/Agents/DeterministicAgentUuid.cs index 518fd8c44..a2cac9c9b 100644 --- a/libraries/MTConnect.NET-Common/Agents/DeterministicAgentUuid.cs +++ b/libraries/MTConnect.NET-Common/Agents/DeterministicAgentUuid.cs @@ -129,5 +129,49 @@ private static byte[] BigEndianToGuidBytes(byte[] beBytes) Buffer.BlockCopy(beBytes, 8, result, 8, 8); return result; } + + /// + /// Validates and normalizes an operator-supplied Agent UUID string. + /// + /// + /// Accepts any format that + /// recognizes — hyphenated "D" (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx), + /// braced "B", parenthesized "P", bare-hex "N", or hex-braced "X" — and + /// returns the canonical hyphenated "D" form so the wire representation + /// is stable regardless of input format. Rejects , + /// empty, whitespace-only, and unparseable inputs. + /// + /// Motivation: MTConnect Part 1 types the uuid attribute as the + /// UUID DataType (RFC 4122). Silently forwarding a non-UUID + /// string emits wire content that fails XSD validation on any typed + /// enum/decimal DataItem and breaks parity with the cppagent reference + /// implementation. Callers that supply malformed input should log a + /// warning and fall through to persisted or derived UUIDs — the + /// three-path resolution in . + /// + /// + /// + /// The raw operator-supplied value from + /// AgentApplicationConfiguration.AgentUuid; may be + /// or empty. + /// + /// + /// On success, the canonical hyphenated "D" form of the parsed UUID + /// (e.g. cfbff0d1-9375-5685-968a-48ce8b50a653); + /// on failure. + /// + /// + /// if parses as an + /// RFC 4122 UUID; for , + /// empty, whitespace-only, or unparseable inputs. + /// + public static bool TryValidate(string input, out string normalized) + { + normalized = null; + if (string.IsNullOrWhiteSpace(input)) return false; + if (!Guid.TryParse(input, out var parsed)) return false; + normalized = parsed.ToString(); + return true; + } } } diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidConfigOverrideTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidConfigOverrideTests.cs index f46444b56..b8951378b 100644 --- a/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidConfigOverrideTests.cs +++ b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidConfigOverrideTests.cs @@ -27,6 +27,7 @@ namespace MTConnect.Tests.Common.Agents /// Mirrors cppagent's AgentDeviceUUID configuration knob. /// [TestFixture] + [NonParallelizable] public class AgentUuidConfigOverrideTests { private string? _stateFilePath; @@ -37,6 +38,18 @@ public class AgentUuidConfigOverrideTests public void SetUp() { _stateFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, MTConnectAgentInformation.Filename); + _backupStateFile = null; + + // Sweep orphan .bak.* files from a prior crashed test run so + // successive TearDowns cannot restore stale state. + var directory = Path.GetDirectoryName(_stateFilePath); + if (directory != null && Directory.Exists(directory)) + { + foreach (var stale in Directory.EnumerateFiles(directory, MTConnectAgentInformation.Filename + ".bak.*")) + { + try { File.Delete(stale); } catch { /* best-effort */ } + } + } // Back up any pre-existing state file so we do not perturb other // tests or the developer environment. @@ -65,38 +78,30 @@ public void TearDown() /// /// Test (a) — pre-condition: no agent.information.json on disk. - /// Setting configuration.AgentUuid pins the agent UUID to that - /// exact value (overriding the Guid.NewGuid() in + /// Setting configuration.AgentUuid to a valid RFC 4122 UUID + /// pins the agent UUID to that exact value (overriding the + /// Guid.NewGuid() in /// 's parameterless ctor). /// [Test] public void AgentUuid_set_in_config_flows_through_to_Agent_uuid() { - const string PinnedUuid = "fixture-stable-uuid-001"; + const string PinnedUuid = "11111111-1111-4111-8111-111111111111"; var configuration = new AgentApplicationConfiguration { AgentUuid = PinnedUuid, }; - // Mirror the exact StartAgent threading slice under test: - // var agentInformation = MTConnectAgentInformation.Read(); - // if (agentInformation == null) agentInformation = new MTConnectAgentInformation(); - // if (!string.IsNullOrEmpty(configuration.AgentUuid)) - // agentInformation.Uuid = configuration.AgentUuid; - var agentInformation = MTConnectAgentInformation.Read(); - if (agentInformation == null) - { - agentInformation = new MTConnectAgentInformation(); - } - if (!string.IsNullOrEmpty(configuration.AgentUuid)) - { - agentInformation.Uuid = configuration.AgentUuid; - } + // Route through the production resolver so the test exercises the + // same code path StartAgent uses (see + // agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs + // RunAgent — resolves via AgentUuidResolver.Resolve). + var agentInformation = ResolveViaProduction(configuration); agentInformation.Save(); // The override must hold both in-memory and after the file - // round-trip that StartAgent performs at line 393. + // round-trip that StartAgent performs. Assert.That(agentInformation.Uuid, Is.EqualTo(PinnedUuid)); var reloaded = MTConnectAgentInformation.Read(); @@ -106,15 +111,16 @@ public void AgentUuid_set_in_config_flows_through_to_Agent_uuid() /// /// Test (b) — pre-condition: agent.information.json already - /// stores a different UUID. The config-level AgentUuid wins. + /// stores a different (valid) UUID. The config-level AgentUuid + /// wins. /// [Test] public void AgentUuid_set_in_config_takes_precedence_over_state_file() { - const string FromStateFileUuid = "from-state-file-uuid"; - const string FromConfigUuid = "from-config-uuid"; + const string FromStateFileUuid = "22222222-2222-4222-8222-222222222222"; + const string FromConfigUuid = "33333333-3333-4333-8333-333333333333"; - // Pre-write the state file with a stale UUID. + // Pre-write the state file with a stale (but valid) UUID. var preexisting = new MTConnectAgentInformation(FromStateFileUuid); preexisting.Save(); @@ -123,15 +129,12 @@ public void AgentUuid_set_in_config_takes_precedence_over_state_file() AgentUuid = FromConfigUuid, }; - var agentInformation = MTConnectAgentInformation.Read(); - Assert.That(agentInformation, Is.Not.Null); - Assert.That(agentInformation!.Uuid, Is.EqualTo(FromStateFileUuid), + var initial = MTConnectAgentInformation.Read(); + Assert.That(initial, Is.Not.Null); + Assert.That(initial!.Uuid, Is.EqualTo(FromStateFileUuid), "Pre-condition: the state file should be read first."); - if (!string.IsNullOrEmpty(configuration.AgentUuid)) - { - agentInformation.Uuid = configuration.AgentUuid; - } + var agentInformation = ResolveViaProduction(configuration); agentInformation.Save(); Assert.That(agentInformation.Uuid, Is.EqualTo(FromConfigUuid)); @@ -157,12 +160,14 @@ public void AgentUuid_set_in_config_takes_precedence_over_state_file() [Test] public void AgentUuid_is_exposed_on_interface_with_camelCase_wire_name() { + const string InterfaceProbe = "44444444-4444-4444-8444-444444444444"; + IAgentApplicationConfiguration configuration = new AgentApplicationConfiguration { - AgentUuid = "interface-surface-test", + AgentUuid = InterfaceProbe, }; - Assert.That(configuration.AgentUuid, Is.EqualTo("interface-surface-test")); + Assert.That(configuration.AgentUuid, Is.EqualTo(InterfaceProbe)); var property = typeof(AgentApplicationConfiguration).GetProperty( nameof(AgentApplicationConfiguration.AgentUuid), @@ -178,5 +183,27 @@ public void AgentUuid_is_exposed_on_interface_with_camelCase_wire_name() "AgentUuid must carry [JsonPropertyName(...)] to match the other config fields."); Assert.That(jsonNameAttribute!.Name, Is.EqualTo("agentUuid")); } + + /// + /// Replays the RunAgent UUID resolution slice via the shared + /// production helper so this fixture cannot silently drift from + /// StartAgent semantics. Returns the populated + /// ready for + /// . + /// + private static MTConnectAgentInformation ResolveViaProduction(AgentApplicationConfiguration configuration) + { + var existing = MTConnectAgentInformation.Read(); + var freshlyConstructed = existing == null; + var info = existing ?? new MTConnectAgentInformation(); + + info.Uuid = AgentUuidResolver.Resolve( + operatorSuppliedUuid: configuration.AgentUuid, + persistedUuid: freshlyConstructed ? null : info.Uuid, + agentName: configuration.ServiceName, + hostname: Environment.MachineName); + + return info; + } } } diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidLongitudinalInvariantsTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidLongitudinalInvariantsTests.cs index dfc745fdf..21369e56d 100644 --- a/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidLongitudinalInvariantsTests.cs +++ b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidLongitudinalInvariantsTests.cs @@ -27,16 +27,28 @@ namespace MTConnect.Tests.Common.Agents /// this file pins the longitudinal invariant. /// [TestFixture] + [NonParallelizable] public class AgentUuidLongitudinalInvariantsTests { private string? _stateFilePath; private string? _backupStateFile; - /// Sets up the fixture before each test. + /// Sets up the fixture before all tests in the class. [OneTimeSetUp] public void OneTimeSetUp() { _stateFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, MTConnectAgentInformation.Filename); + _backupStateFile = null; + + // Sweep orphan .longinv.bak.* files from a prior crashed test run. + var directory = Path.GetDirectoryName(_stateFilePath); + if (directory != null && Directory.Exists(directory)) + { + foreach (var stale in Directory.EnumerateFiles(directory, MTConnectAgentInformation.Filename + ".longinv.bak.*")) + { + try { File.Delete(stale); } catch { /* best-effort */ } + } + } // Back up any pre-existing state file so we do not perturb other // tests or the developer environment. @@ -81,33 +93,30 @@ public void OneTimeTearDown() /// call to MTConnectAgentApplication.StartAgent, followed by the /// broker's post-device-add persist. /// - /// Production sources replayed (verify against live code if either drifts): - /// - /// - /// agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs - /// lines 351–404 — Read, AgentUuid override, InstanceId zeroing, Save. - /// - /// - /// libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs - /// lines 207–234 — broker ctor: _instanceId = instanceId > 0 ? instanceId : CreateInstanceId() - /// where CreateInstanceId() returns (ulong)(UnixDateTime.Now / 1000 / 10000) - /// (line 2351–2353), and lines 2321–2345 — UpdateAgentInformation timer-driven - /// persist after device-add. - /// - /// + /// The UUID resolution slice routes through + /// — the same call production + /// makes — so this fixture cannot silently drift from StartAgent + /// semantics (branch order, guard shape, validation). + /// + /// InstanceId handling remains inline because it has no shared + /// helper: it depends on configuration.Durable + + /// durableBufferLoadSucceeds and mirrors the broker ctor's + /// _instanceId = instanceId > 0 ? instanceId : CreateInstanceId() + /// contract (libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs). /// private static (string uuid, ulong instanceId) SimulateBoot( AgentApplicationConfiguration configuration, bool durableBufferLoadSucceeds) { - // Mirrors MTConnectAgentApplication.StartAgent lines 351–404: - var info = MTConnectAgentInformation.Read(); - if (info == null) info = new MTConnectAgentInformation(); + var existing = MTConnectAgentInformation.Read(); + var freshlyConstructed = existing == null; + var info = existing ?? new MTConnectAgentInformation(); - if (!string.IsNullOrEmpty(configuration.AgentUuid)) - { - info.Uuid = configuration.AgentUuid; - } + info.Uuid = AgentUuidResolver.Resolve( + operatorSuppliedUuid: configuration.AgentUuid, + persistedUuid: freshlyConstructed ? null : info.Uuid, + agentName: configuration.ServiceName, + hostname: Environment.MachineName); var initializeDataItems = !durableBufferLoadSucceeds; if (!configuration.Durable || initializeDataItems) @@ -117,17 +126,16 @@ private static (string uuid, ulong instanceId) SimulateBoot( info.Save(); - // Mirrors MTConnectAgent ctor (MTConnectAgent.cs lines 207–234): + // Mirrors MTConnectAgent ctor: // _instanceId = instanceId > 0 ? instanceId : CreateInstanceId(); - // CreateInstanceId() returns (ulong)(UnixDateTime.Now / 1000 / 10000) — Unix epoch seconds. + // CreateInstanceId() = (ulong)(UnixDateTime.Now / 1000 / 10000) — Unix epoch seconds. var brokerInstanceId = info.InstanceId > 0 ? info.InstanceId : (ulong)(UnixDateTime.Now / 1000 / 10000); - // Mirrors MTConnectAgent.UpdateAgentInformation (MTConnectAgent.cs lines 2321–2345): - // The broker writes its chosen _instanceId back to the file via a timer-driven - // persist once a device is added. Simulate that here so the next boot's Read() - // sees the broker's resolved InstanceId, not the zeroed value from the Save() above. + // Broker writes _instanceId back via UpdateAgentInformation once a + // device is added; simulate that so the next boot's Read() sees + // the broker's resolved InstanceId, not the zeroed value. info.InstanceId = brokerInstanceId; info.Save(); @@ -143,9 +151,11 @@ private static (string uuid, ulong instanceId) SimulateBoot( [Test] public void Uuid_pinned_via_config_survives_two_non_durable_boots() { + const string PinnedUuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + var configuration = new AgentApplicationConfiguration { - AgentUuid = "fixture-stable-uuid-A", + AgentUuid = PinnedUuid, Durable = false, }; @@ -156,9 +166,9 @@ public void Uuid_pinned_via_config_survives_two_non_durable_boots() var (uuid2, instanceId2) = SimulateBoot(configuration, durableBufferLoadSucceeds: false); - Assert.That(uuid1, Is.EqualTo("fixture-stable-uuid-A"), + Assert.That(uuid1, Is.EqualTo(PinnedUuid), "Boot 1: config-level AgentUuid must be applied."); - Assert.That(uuid2, Is.EqualTo("fixture-stable-uuid-A"), + Assert.That(uuid2, Is.EqualTo(PinnedUuid), "Boot 2: config-level AgentUuid must survive a non-durable restart."); Assert.That(instanceId1, Is.Not.EqualTo(instanceId2), "Non-durable buffer means the InstanceId resets each boot — the UUIDs are equal but InstanceIds differ."); @@ -178,9 +188,11 @@ public void Uuid_pinned_via_config_survives_two_non_durable_boots() [Test] public void Uuid_pinned_via_config_survives_durable_boot_with_buffer_load_success() { + const string PinnedUuid = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + var configuration = new AgentApplicationConfiguration { - AgentUuid = "fixture-stable-uuid-B", + AgentUuid = PinnedUuid, Durable = true, }; @@ -192,28 +204,26 @@ public void Uuid_pinned_via_config_survives_durable_boot_with_buffer_load_succes // Boot 2: warm restart — durable buffer loaded successfully. var (uuid2, instanceId2) = SimulateBoot(configuration, durableBufferLoadSucceeds: true); - Assert.That(uuid1, Is.EqualTo("fixture-stable-uuid-B"), + Assert.That(uuid1, Is.EqualTo(PinnedUuid), "Boot 1: config-level AgentUuid must be applied."); - Assert.That(uuid2, Is.EqualTo("fixture-stable-uuid-B"), + Assert.That(uuid2, Is.EqualTo(PinnedUuid), "Boot 2: config-level AgentUuid must survive a durable restart."); Assert.That(instanceId1, Is.EqualTo(instanceId2), "Durable buffer load success means InstanceId is preserved across boots (spec requirement)."); } /// - /// Documents the pre-fix bug from the consumer's perspective: - /// when AgentApplicationConfiguration.AgentUuid is null - /// and no state file persists across boots (e.g., an ephemeral container), - /// both UUID and InstanceId regenerate on every boot. - /// - /// This is not a regression check on the new feature — it is a - /// regression check on the bug itself still being a bug when the knob - /// is absent. Deleting the state file between boots simulates the - /// "no persistent storage" scenario that the new config knob lets - /// consumers escape. + /// Post-fix longitudinal invariant: when + /// AgentApplicationConfiguration.AgentUuid is + /// and no state file persists across boots (e.g. an ephemeral container), + /// the meta-device UUID is nevertheless stable across boots because + /// Path 3 derives it + /// deterministically from (agentName ?? hostname, hostname, port: 0). + /// The InstanceId still resets each boot because the durable + /// buffer did not load (spec-correct behaviour for Header.instanceId). /// [Test] - public void Uuid_not_pinned_and_no_state_file_regenerates_per_boot() + public void Uuid_not_pinned_and_no_state_file_is_stable_via_deterministic_derivation() { var configuration = new AgentApplicationConfiguration { @@ -234,11 +244,14 @@ public void Uuid_not_pinned_and_no_state_file_regenerates_per_boot() var (uuid2, instanceId2) = SimulateBoot(configuration, durableBufferLoadSucceeds: false); - Assert.That(uuid1, Is.Not.EqualTo(uuid2), - "No AgentUuid override + no state file = a fresh Guid is generated every boot. " + - "This is the pre-fix problem the new knob lets consumers avoid."); + Assert.That(uuid1, Is.EqualTo(uuid2), + "No override + no state file, but Path 3 derives deterministically " + + "from (agentName ?? hostname, hostname, port) — the meta-device UUID is stable " + + "across boots. This is the whole point of the AgentUuidResolver + " + + "DeterministicAgentUuid.Derive stack introduced by #168."); Assert.That(instanceId1, Is.Not.EqualTo(instanceId2), - "No state file = InstanceId is also regenerated each boot."); + "No state file = InstanceId is still regenerated each boot " + + "(Header.instanceId is per-boot by spec; separate concern from UUID stability)."); } } } diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidValidationTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidValidationTests.cs new file mode 100644 index 000000000..c404831df --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidValidationTests.cs @@ -0,0 +1,379 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.IO; +using MTConnect.Agents; +using MTConnect.Configurations; +using NUnit.Framework; + +namespace MTConnect.Tests.Common.Agents +{ + /// + /// Pins the contract that + /// gates every source of the Agent meta-device UUID (operator-supplied + /// override AND persisted agent.information.json state) so that + /// malformed values — anything that does not parse as an RFC 4122 UUID — + /// are rejected on the way in and the resolution falls through to the + /// next path (persisted → derived). + /// + /// + /// Silently forwarding a non-UUID string violates MTConnect Part 1, which + /// types the uuid attribute as the UUID DataType (RFC 4122 + /// enumerated string token). Any downstream XSD-validating consumer + /// (cppagent parity, MQTT/JSON-cppagent transport) rejects the resulting + /// wire content on typed enum/decimal DataItems. + /// + /// + /// + /// Fixture drives directly — the + /// same method MTConnectAgentApplication.StartAgent calls — so + /// production and tests cannot silently diverge on branch order, guard + /// semantics, or normalisation output. + /// + /// + [TestFixture] + [NonParallelizable] + public class AgentUuidValidationTests + { + private string _stateFilePath = null!; + private string? _backupStateFile; + + /// Sets up the fixture before each test. + [SetUp] + public void SetUp() + { + _stateFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, MTConnectAgentInformation.Filename); + _backupStateFile = null; + + // Sweep orphan .valbak.* files from a prior crashed test run so + // successive TearDowns cannot restore stale state. + var directory = Path.GetDirectoryName(_stateFilePath); + if (directory != null && Directory.Exists(directory)) + { + foreach (var stale in Directory.EnumerateFiles(directory, MTConnectAgentInformation.Filename + ".valbak.*")) + { + try { File.Delete(stale); } catch { /* best-effort — do not fail the test */ } + } + } + + // Back up any pre-existing state file so we do not perturb other + // tests or the developer environment. + if (File.Exists(_stateFilePath)) + { + _backupStateFile = _stateFilePath + ".valbak." + Guid.NewGuid().ToString("N"); + File.Move(_stateFilePath, _backupStateFile); + } + } + + /// Tears down the fixture after each test. + [TearDown] + public void TearDown() + { + // Remove any state file we left behind, then restore the backup. + if (File.Exists(_stateFilePath)) + { + File.Delete(_stateFilePath); + } + if (_backupStateFile != null && File.Exists(_backupStateFile)) + { + File.Move(_backupStateFile, _stateFilePath); + _backupStateFile = null; + } + } + + // ------------------------------------------------------------------ + // Unit tests — DeterministicAgentUuid.TryValidate low-level contract + // ------------------------------------------------------------------ + + /// + /// rejects + /// , empty, and whitespace-only inputs. + /// + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("\t")] + [TestCase("\n")] + public void TryValidate_null_or_whitespace_returns_false(string input) + { + var ok = DeterministicAgentUuid.TryValidate(input, out var normalized); + + Assert.That(ok, Is.False); + Assert.That(normalized, Is.Null); + } + + /// + /// rejects strings + /// that do not parse as RFC 4122 UUIDs. + /// + [TestCase("not-a-uuid")] + [TestCase("fixture-stable-uuid-001")] + [TestCase("agent_1234567890abcdef")] + [TestCase("123")] + [TestCase("6ba7b810-9dad-11d1-80b4-00c04fd430c8-extra")] + public void TryValidate_unparseable_returns_false(string input) + { + var ok = DeterministicAgentUuid.TryValidate(input, out var normalized); + + Assert.That(ok, Is.False); + Assert.That(normalized, Is.Null); + } + + /// + /// accepts the + /// canonical hyphenated "D" form and returns it unchanged. + /// + [Test] + public void TryValidate_canonical_hyphenated_form_returns_true_unchanged() + { + const string Canonical = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; + + var ok = DeterministicAgentUuid.TryValidate(Canonical, out var normalized); + + Assert.That(ok, Is.True); + Assert.That(normalized, Is.EqualTo(Canonical)); + } + + /// + /// normalizes the + /// braced "B", parenthesised "P", and bare-hex "N" forms to the + /// canonical hyphenated "D" form so the wire representation stays + /// stable regardless of the input format. + /// + [TestCase("{6ba7b810-9dad-11d1-80b4-00c04fd430c8}", "6ba7b810-9dad-11d1-80b4-00c04fd430c8")] + [TestCase("(6ba7b810-9dad-11d1-80b4-00c04fd430c8)", "6ba7b810-9dad-11d1-80b4-00c04fd430c8")] + [TestCase("6ba7b8109dad11d180b400c04fd430c8", "6ba7b810-9dad-11d1-80b4-00c04fd430c8")] + public void TryValidate_non_canonical_format_normalizes_to_hyphenated(string input, string expected) + { + var ok = DeterministicAgentUuid.TryValidate(input, out var normalized); + + Assert.That(ok, Is.True); + Assert.That(normalized, Is.EqualTo(expected)); + } + + // ------------------------------------------------------------------ + // Integration tests — AgentUuidResolver.Resolve three-path algorithm + // ------------------------------------------------------------------ + + /// + /// Boot-simulation regression — malformed + /// AgentApplicationConfiguration.AgentUuid on a fresh boot + /// (no agent.information.json) must fall through to the + /// derived UUID rather than being silently stored verbatim. + /// + [Test] + public void Malformed_AgentUuid_on_fresh_boot_falls_through_to_derived() + { + const string MalformedInput = "not-a-uuid"; + const string ServiceName = "test-agent-malformed-fresh"; + var hostname = Environment.MachineName; + + var configuration = new AgentApplicationConfiguration + { + AgentUuid = MalformedInput, + ServiceName = ServiceName, + }; + + var resolved = SimulateFreshBoot(configuration, hostname); + + var expectedDerived = DeterministicAgentUuid.Derive(ServiceName, hostname, port: 0); + Assert.That(resolved, Is.EqualTo(expectedDerived), + "Malformed operator override on a fresh boot must fall through to the derived UUID."); + Assert.That(resolved, Is.Not.EqualTo(MalformedInput), + "The malformed input must NOT be stored verbatim (Part-1 UuidType violation)."); + } + + /// + /// Boot-simulation regression — malformed + /// AgentApplicationConfiguration.AgentUuid when a valid + /// agent.information.json exists must preserve the persisted + /// UUID rather than being silently stored verbatim. + /// + [Test] + public void Malformed_AgentUuid_with_valid_persisted_state_preserves_persisted() + { + const string MalformedInput = "not-a-uuid"; + const string PersistedUuid = "cfbff0d1-9375-5685-968a-48ce8b50a653"; + + // Pre-write the state file with a valid UUID. + var preexisting = new MTConnectAgentInformation(PersistedUuid); + preexisting.Save(); + + var configuration = new AgentApplicationConfiguration + { + AgentUuid = MalformedInput, + ServiceName = "test-agent-malformed-persisted", + }; + + var resolved = SimulateWarmBoot(configuration, Environment.MachineName); + + Assert.That(resolved, Is.EqualTo(PersistedUuid), + "Malformed operator override with valid persisted state must keep the persisted UUID."); + Assert.That(resolved, Is.Not.EqualTo(MalformedInput), + "The malformed input must NOT overwrite the persisted UUID."); + } + + /// + /// Path-2 hardening — malformed persisted state (e.g. a pre-hardening + /// agent version wrote a non-UUID string, or the file was hand-edited) + /// must ALSO fall through to the derived UUID rather than flowing on + /// to the wire. Prevents the exact XSD-validation failure the PR was + /// written to prevent, closed on the persisted-state axis too. + /// + [Test] + public void Malformed_persisted_state_with_no_override_falls_through_to_derived() + { + const string MalformedPersisted = "agent_1234567890abcdef"; + const string ServiceName = "test-agent-malformed-persisted-path2"; + var hostname = Environment.MachineName; + + var preexisting = new MTConnectAgentInformation(MalformedPersisted); + preexisting.Save(); + + var configuration = new AgentApplicationConfiguration + { + AgentUuid = null, + ServiceName = ServiceName, + }; + + var resolved = SimulateWarmBoot(configuration, hostname); + + var expectedDerived = DeterministicAgentUuid.Derive(ServiceName, hostname, port: 0); + Assert.That(resolved, Is.EqualTo(expectedDerived), + "Malformed persisted UUID with no override must fall through to derived — Path 2 must validate."); + Assert.That(resolved, Is.Not.EqualTo(MalformedPersisted), + "The malformed persisted value must NOT reach the wire."); + } + + /// + /// Boot-simulation regression — valid non-canonical + /// AgentApplicationConfiguration.AgentUuid (e.g. braced form) + /// is accepted and normalized to the canonical hyphenated form so the + /// wire representation stays stable across boots. + /// + [Test] + public void Valid_non_canonical_AgentUuid_is_normalized_to_hyphenated() + { + const string BracedInput = "{6ba7b810-9dad-11d1-80b4-00c04fd430c8}"; + const string ExpectedCanonical = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; + + var configuration = new AgentApplicationConfiguration + { + AgentUuid = BracedInput, + ServiceName = "test-agent-canonicalization", + }; + + var resolved = SimulateFreshBoot(configuration, Environment.MachineName); + + Assert.That(resolved, Is.EqualTo(ExpectedCanonical), + "Non-canonical valid UUID inputs must be normalized to the hyphenated D-form."); + } + + /// + /// Two-boot regression — first boot with no override and no state file + /// derives + persists a UUID; second boot with no override reads the + /// persisted UUID from agent.information.json and adopts it + /// verbatim (Path 2). Bit-identical across the two boots. + /// + [Test] + public void Persisted_UUID_is_adopted_on_second_boot_when_no_override() + { + const string ServiceName = "test-agent-persisted-second-boot"; + var hostname = Environment.MachineName; + + var configuration = new AgentApplicationConfiguration + { + AgentUuid = null, + ServiceName = ServiceName, + }; + + // Boot 1 — no override, no state file → derive + persist. + var boot1Uuid = SimulateFreshBoot(configuration, hostname); + Assert.That(File.Exists(_stateFilePath), Is.True, + "Boot 1 must persist agent.information.json with the resolved UUID."); + var persistedAfterBoot1 = MTConnectAgentInformation.Read(); + Assert.That(persistedAfterBoot1, Is.Not.Null); + Assert.That(persistedAfterBoot1!.Uuid, Is.EqualTo(boot1Uuid), + "Boot 1's on-disk UUID must equal the resolved value."); + + // Boot 2 — no override, but persisted state exists → adopt persisted. + var boot2Uuid = SimulateWarmBoot(configuration, hostname); + Assert.That(boot2Uuid, Is.EqualTo(boot1Uuid), + "Boot 2 must adopt the persisted UUID bit-identically (Path 2)."); + } + + /// + /// First-boot persistence regression — no override, no state file → + /// Path 3 derives the UUID AND + /// writes it to agent.information.json so subsequent boots hit + /// Path 2. Guards against a regression where the resolve step returns + /// the derived value but the persist step is dropped. + /// + [Test] + public void First_boot_persists_derived_UUID_to_agent_information_json() + { + const string ServiceName = "test-agent-first-boot-persist"; + var hostname = Environment.MachineName; + + var configuration = new AgentApplicationConfiguration + { + AgentUuid = null, + ServiceName = ServiceName, + }; + + var resolved = SimulateFreshBoot(configuration, hostname); + + Assert.That(File.Exists(_stateFilePath), Is.True, + "agent.information.json must exist after the first boot."); + var persisted = MTConnectAgentInformation.Read(); + Assert.That(persisted, Is.Not.Null); + Assert.That(persisted!.Uuid, Is.EqualTo(resolved), + "Persisted UUID on disk must equal the resolved value."); + var expectedDerived = DeterministicAgentUuid.Derive(ServiceName, hostname, port: 0); + Assert.That(persisted.Uuid, Is.EqualTo(expectedDerived), + "Persisted UUID must equal the deterministic derivation for the given ServiceName."); + } + + // ------------------------------------------------------------------ + // Boot-simulation helpers — thin wrappers around AgentUuidResolver. + // ------------------------------------------------------------------ + + /// + /// Replays the fresh-boot UUID resolution slice of + /// MTConnectAgentApplication.StartAgent — no + /// agent.information.json on disk — via + /// (the exact call production + /// makes) followed by so + /// on-disk state assertions can verify persistence. + /// + private string SimulateFreshBoot( + AgentApplicationConfiguration configuration, + string hostname) + { + var existing = MTConnectAgentInformation.Read(); + var freshlyConstructed = existing == null; + var info = existing ?? new MTConnectAgentInformation(); + + info.Uuid = AgentUuidResolver.Resolve( + operatorSuppliedUuid: configuration.AgentUuid, + persistedUuid: freshlyConstructed ? null : info.Uuid, + agentName: configuration.ServiceName, + hostname: hostname); + + info.Save(); + return info.Uuid; + } + + /// + /// Replays the warm-boot UUID resolution slice — pre-existing + /// agent.information.json is read first — via + /// . Semantically identical to + /// ; two named helpers make the + /// per-test intent (fresh vs warm) unambiguous at the call site. + /// + private string SimulateWarmBoot( + AgentApplicationConfiguration configuration, + string hostname) => SimulateFreshBoot(configuration, hostname); + } +}