using System.Text; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Parser-level coverage for (#520 stage 4) /// over hand-written JSON — the shapes a composed artifact cannot produce from the in-memory EF /// provider, plus the malformed rows the lenient-decode contract has to survive. /// /// /// The happy path is deliberately NOT tested here. It is tested in /// ControlPlane.Tests/DataPlaneAuthorityArtifactTests against an artifact a real /// ConfigComposer produced, because a hand-written fixture pins the parser against this file's /// idea of the format rather than against the producer. /// public sealed class DeploymentArtifactDataPlaneAuthorityTests { private const string NodeId = "node-a:4840"; [Fact] public void An_expiry_written_without_an_offset_is_read_as_UTC() { // SQL Server's datetime2 carries no offset, so EF materialises it as DateTimeKind.Unspecified and // the composer writes "2030-06-15T12:00:00" with no Z. Calling ToUniversalTime() on that would // SUBTRACT the node's local offset from a value that is already UTC, moving every key's expiry by // hours — earlier in the Americas, so keys would appear to expire before they should. The // in-memory provider preserves Kind and cannot reproduce this, which is why it is pinned here. var parsed = Parse($$""" {"ApiKeys":[{"ApiKeyId":"abc","Name":"k","SecretHash":"h","Principal":"p","Enabled":true, "CreatedUtc":"2026-01-01T00:00:00","ExpiresUtc":"2030-06-15T12:00:00"}]} """); var key = parsed.Keys.ShouldHaveSingleItem(); key.ExpiresUtc.ShouldBe(new DateTime(2030, 6, 15, 12, 0, 0, DateTimeKind.Utc)); key.ExpiresUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc); } [Fact] public void An_expiry_written_with_a_Z_is_also_read_as_the_same_instant() { // The positive control for the test above: without it, a parser that ignored ExpiresUtc entirely // and returned a hardcoded value would satisfy neither, but one that mangled only the Z-suffixed // form would slip through. var parsed = Parse(""" {"ApiKeys":[{"ApiKeyId":"abc","Name":"k","SecretHash":"h","Principal":"p","Enabled":true, "ExpiresUtc":"2030-06-15T12:00:00Z"}]} """); parsed.Keys.ShouldHaveSingleItem().ExpiresUtc .ShouldBe(new DateTime(2030, 6, 15, 12, 0, 0, DateTimeKind.Utc)); } [Fact] public void A_key_row_missing_its_secret_hash_is_dropped_rather_than_admitted() { // A half-written row must never become a credential. Dropping it denies that key id; keeping it // with an empty hash would hand the verifier something to compare against. var parsed = Parse(""" {"ApiKeys":[{"ApiKeyId":"abc","Name":"k","Principal":"p","Enabled":true}]} """); parsed.Keys.ShouldBeEmpty(); } [Fact] public void A_key_row_with_no_Enabled_field_is_treated_as_revoked() { // The other direction would resurrect a revoked key out of a malformed artifact. var parsed = Parse(""" {"ApiKeys":[{"ApiKeyId":"abc","Name":"k","SecretHash":"h","Principal":"p"}]} """); parsed.Keys.ShouldHaveSingleItem().Enabled.ShouldBeFalse(); } [Fact] public void An_out_of_range_scope_kind_drops_the_grant() { // Casting an undefined int straight to the enum would produce a scope the trie walker can never // match, i.e. a grant that is silently unreachable rather than visibly absent. var parsed = Parse(""" {"NodeAcls":[{"NodeAclId":"acl-1","ClusterId":"c","Principal":"p","ScopeKind":99,"PermissionFlags":1}]} """); parsed.Grants.ShouldBeEmpty(); } [Fact] public void A_grant_with_an_unreadable_permission_mask_grants_nothing() { // Defaulting a missing bitmask to anything other than None would invent authority. var parsed = Parse(""" {"NodeAcls":[{"NodeAclId":"acl-1","ClusterId":"c","Principal":"p","ScopeKind":0}]} """); parsed.Grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.None); } [Fact] public void A_string_valued_scope_kind_drops_the_grant() { // Guards the enum-serialization drift this repo has been bitten by before: if the composer ever // gained a JsonStringEnumConverter, grants would arrive as "Cluster" instead of 0. That must fail // loudly in tests rather than silently drop every grant in production — the ControlPlane // round-trip test is what goes red; this one documents the shape the parser refuses. var parsed = Parse(""" {"NodeAcls":[{"NodeAclId":"acl-1","ClusterId":"c","Principal":"p","ScopeKind":"Cluster","PermissionFlags":1}]} """); parsed.Grants.ShouldBeEmpty(); } [Fact] public void An_artifact_with_neither_section_yields_empty_lists_not_a_throw() { var parsed = Parse("""{"DriverInstances":[]}"""); parsed.Keys.ShouldBeEmpty(); parsed.Grants.ShouldBeEmpty(); parsed.ClusterId.ShouldBeNull(); } private static DataPlaneAuthorityProjection Parse(string json) => DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(json), NodeId); }