Skip to content

Commit c8bafd2

Browse files
authored
Add test coverage for JSON user database and state machine availability (#3992)
# Description Adds focused tests for production paths introduced in #3980 that remained below the repository's 80% Codecov patch-coverage threshold. The existing fixtures now cover: - Current and legacy `ExpandedNodeId` JSON representations used by `JsonUserDatabase`, including numeric, string, GUID, opaque, null, absolute, and malformed role identifiers. - Optional or null `AvailableStates` and `AvailableTransitions` values in `FiniteStateMachineTypeClientExtensions`. The tests assert the exact loaded users, roles, identifiers, fallback behavior, and state-machine results rather than only executing converter branches. Local changed-line measurement reports: - `JsonUserDatabase.cs`: 100% line coverage. - Changed executable lines in `FiniteStateMachineTypeClientExtensions.cs`: 100% coverage. Focused validation passes on .NET 10 and .NET Framework 4.8: 58 tests passed. ## Related Issues Follow-up to #3980. ## Checklist - [x] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [x] I have addressed **all** PR feedback received.
1 parent c8bc1bb commit c8bafd2

2 files changed

Lines changed: 354 additions & 0 deletions

File tree

Tests/Opc.Ua.Client.Tests/StateMachines/FiniteStateMachineTypeClientExtensionsTests.cs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,117 @@ public async Task GetAvailableNodesAsyncReturnsEmptyWhenOptionalPropertiesAreAbs
230230
It.IsAny<CancellationToken>()), Times.Never);
231231
}
232232

233+
[Test]
234+
public async Task GetAvailableStatesAsyncReturnsEmptyWhenAvailablePropertyResolvesToNullNodeIdAsync()
235+
{
236+
var sessionMock = new Mock<ISessionClient>(MockBehavior.Loose);
237+
FiniteStateMachineTypeClient client = CreateClient(sessionMock);
238+
239+
sessionMock.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync(
240+
It.IsAny<RequestHeader>(),
241+
It.IsAny<ArrayOf<BrowsePath>>(),
242+
It.IsAny<CancellationToken>()))
243+
.Returns<RequestHeader, ArrayOf<BrowsePath>, CancellationToken>(
244+
(_, _, _) => new ValueTask<TranslateBrowsePathsToNodeIdsResponse>(
245+
new TranslateBrowsePathsToNodeIdsResponse
246+
{
247+
ResponseHeader = new ResponseHeader(),
248+
Results =
249+
[
250+
new BrowsePathResult
251+
{
252+
StatusCode = StatusCodes.Good,
253+
Targets =
254+
[
255+
new BrowsePathTarget
256+
{
257+
TargetId = ExpandedNodeId.Null,
258+
RemainingPathIndex = uint.MaxValue
259+
}
260+
]
261+
}
262+
],
263+
DiagnosticInfos = []
264+
}));
265+
266+
IReadOnlyList<FiniteStateInfo> states = await client
267+
.GetAvailableStatesAsync().ConfigureAwait(false);
268+
269+
Assert.That(states, Is.Empty);
270+
sessionMock.Verify(s => s.ReadAsync(
271+
It.IsAny<RequestHeader>(),
272+
It.IsAny<double>(),
273+
It.IsAny<TimestampsToReturn>(),
274+
It.IsAny<ArrayOf<ReadValueId>>(),
275+
It.IsAny<CancellationToken>()), Times.Never);
276+
}
277+
278+
[Test]
279+
public async Task GetAvailableTransitionsAsyncReturnsEmptyWhenAvailableNodeIdsAreAllNullAsync()
280+
{
281+
var sessionMock = new Mock<ISessionClient>(MockBehavior.Loose);
282+
FiniteStateMachineTypeClient client = CreateClient(sessionMock);
283+
NodeId availableTransitionsNode = new(106u, 2);
284+
285+
sessionMock.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync(
286+
It.IsAny<RequestHeader>(),
287+
It.IsAny<ArrayOf<BrowsePath>>(),
288+
It.IsAny<CancellationToken>()))
289+
.Returns<RequestHeader, ArrayOf<BrowsePath>, CancellationToken>(
290+
(_, _, _) => new ValueTask<TranslateBrowsePathsToNodeIdsResponse>(
291+
new TranslateBrowsePathsToNodeIdsResponse
292+
{
293+
ResponseHeader = new ResponseHeader(),
294+
Results =
295+
[
296+
new BrowsePathResult
297+
{
298+
StatusCode = StatusCodes.Good,
299+
Targets =
300+
[
301+
new BrowsePathTarget
302+
{
303+
TargetId = availableTransitionsNode,
304+
RemainingPathIndex = uint.MaxValue
305+
}
306+
]
307+
}
308+
],
309+
DiagnosticInfos = []
310+
}));
311+
sessionMock.Setup(s => s.ReadAsync(
312+
It.IsAny<RequestHeader>(),
313+
0,
314+
TimestampsToReturn.Neither,
315+
It.IsAny<ArrayOf<ReadValueId>>(),
316+
It.IsAny<CancellationToken>()))
317+
.Returns<RequestHeader, double, TimestampsToReturn, ArrayOf<ReadValueId>, CancellationToken>(
318+
(_, _, _, _, _) => new ValueTask<ReadResponse>(new ReadResponse
319+
{
320+
ResponseHeader = new ResponseHeader(),
321+
Results =
322+
[
323+
new DataValue(Variant.From(new[] { NodeId.Null, NodeId.Null }.ToArrayOf()))
324+
],
325+
DiagnosticInfos = []
326+
}));
327+
328+
IReadOnlyList<FiniteTransitionInfo> transitions = await client
329+
.GetAvailableTransitionsAsync().ConfigureAwait(false);
330+
331+
Assert.That(transitions, Is.Empty);
332+
sessionMock.Verify(s => s.TranslateBrowsePathsToNodeIdsAsync(
333+
It.IsAny<RequestHeader>(),
334+
It.IsAny<ArrayOf<BrowsePath>>(),
335+
It.IsAny<CancellationToken>()), Times.Once);
336+
sessionMock.Verify(s => s.ReadAsync(
337+
It.IsAny<RequestHeader>(),
338+
It.IsAny<double>(),
339+
It.IsAny<TimestampsToReturn>(),
340+
It.IsAny<ArrayOf<ReadValueId>>(),
341+
It.IsAny<CancellationToken>()), Times.Once);
342+
}
343+
233344
[Test]
234345
public async Task GetCurrentFiniteStateAsyncReturnsBadNotFoundEmptySnapshotWhenAllBrowsePathsResolveNull()
235346
{

Tests/Opc.Ua.Server.Tests/JsonUserDatabaseTests.cs

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@
2828
* ======================================================================*/
2929

3030
using System;
31+
using System.Collections.Generic;
3132
using System.IO;
3233
using System.Linq;
34+
using System.Text.Json;
3335
using NUnit.Framework;
3436
using Opc.Ua.Server.UserDatabase;
3537
using Opc.Ua.Tests;
@@ -151,6 +153,121 @@ public void LoadLegacyObjectDatabasePreservesUsersCredentialsAndRoles()
151153
Assert.That(loaded.GetUserRoles("alice"), Does.Contain(Role.Engineer));
152154
}
153155

156+
[Test]
157+
public void LoadCurrentStringRoleIdPreservesAbsoluteIdentifier()
158+
{
159+
var expectedRoleId = new ExpandedNodeId(
160+
"maintenance",
161+
0,
162+
"urn:example:roles",
163+
2);
164+
string json = CreateDatabaseJson(JsonSerializer.Serialize(expectedRoleId.ToString()));
165+
166+
JsonUserDatabase loaded = LoadDatabase(json);
167+
168+
Assert.That(loaded.GetUsers().Single().UserName, Is.EqualTo("alice"));
169+
Assert.That(loaded.GetUserRoles("alice").Single().Name, Is.EqualTo("Custom"));
170+
Assert.That(loaded.GetUserRoles("alice").Single().RoleId, Is.EqualTo(expectedRoleId));
171+
}
172+
173+
[Test]
174+
public void LoadLegacyNullRoleIdPreservesNullIdentifier()
175+
{
176+
const string roleIdJson = """
177+
{
178+
"serverIndex": 0,
179+
"isNull": true,
180+
"isAbsolute": false,
181+
"namespaceIndex": 0,
182+
"idType": "numeric"
183+
}
184+
""";
185+
186+
JsonUserDatabase loaded = LoadDatabase(CreateDatabaseJson(roleIdJson));
187+
188+
Assert.That(loaded.GetUsers().Single().UserName, Is.EqualTo("alice"));
189+
Assert.That(loaded.GetUserRoles("alice").Single().RoleId, Is.EqualTo(ExpandedNodeId.Null));
190+
}
191+
192+
[Test]
193+
public void LoadLegacyStringRoleIdPreservesIdentifier()
194+
{
195+
const string roleIdJson = """
196+
{
197+
"serverIndex": 0,
198+
"isNull": false,
199+
"isAbsolute": false,
200+
"namespaceIndex": 2,
201+
"idType": "string",
202+
"identifier": "custom-role"
203+
}
204+
""";
205+
206+
JsonUserDatabase loaded = LoadDatabase(CreateDatabaseJson(roleIdJson));
207+
208+
Assert.That(
209+
loaded.GetUserRoles("alice").Single().RoleId,
210+
Is.EqualTo(new ExpandedNodeId("custom-role", 2)));
211+
}
212+
213+
[Test]
214+
public void LoadLegacyGuidRoleIdPreservesIdentifier()
215+
{
216+
const string roleIdJson = """
217+
{
218+
"serverIndex": 0,
219+
"isNull": false,
220+
"isAbsolute": true,
221+
"namespaceIndex": 0,
222+
"namespaceUri": "urn:example:roles",
223+
"idType": "guid",
224+
"identifier": "65c5a128-701c-4a46-9d64-9c4dfc78bc9d"
225+
}
226+
""";
227+
228+
JsonUserDatabase loaded = LoadDatabase(CreateDatabaseJson(roleIdJson));
229+
230+
Assert.That(
231+
loaded.GetUserRoles("alice").Single().RoleId,
232+
Is.EqualTo(
233+
new ExpandedNodeId(
234+
Guid.Parse("65c5a128-701c-4a46-9d64-9c4dfc78bc9d"),
235+
0,
236+
"urn:example:roles",
237+
0)));
238+
}
239+
240+
[Test]
241+
public void LoadLegacyOpaqueRoleIdPreservesIdentifier()
242+
{
243+
const string roleIdJson = """
244+
{
245+
"serverIndex": 3,
246+
"isNull": false,
247+
"isAbsolute": true,
248+
"namespaceIndex": 1,
249+
"idType": "opaque",
250+
"identifier": {
251+
"memory": "AQIDBA=="
252+
}
253+
}
254+
""";
255+
256+
JsonUserDatabase loaded = LoadDatabase(CreateDatabaseJson(roleIdJson));
257+
258+
Assert.That(
259+
loaded.GetUserRoles("alice").Single().RoleId,
260+
Is.EqualTo(new ExpandedNodeId(ByteString.From([1, 2, 3, 4]), 1, null, 3)));
261+
}
262+
263+
[TestCaseSource(nameof(InvalidRoleIdJson))]
264+
public void LoadInvalidRoleIdReturnsEmptyDatabase(string roleIdJson)
265+
{
266+
JsonUserDatabase loaded = LoadDatabase(CreateDatabaseJson(roleIdJson));
267+
268+
Assert.That(loaded.GetUsers(), Is.Empty);
269+
}
270+
154271
[Test]
155272
public void LoadExistingEmptyJsonPreservesFileName()
156273
{
@@ -175,6 +292,132 @@ public void LoadInvalidJsonLogsAndReturnsEmptyDatabase()
175292
Assert.That(loaded.GetUsers(), Is.Empty);
176293
}
177294

295+
private static IEnumerable<TestCaseData> InvalidRoleIdJson()
296+
{
297+
yield return new TestCaseData("\"not-an-expanded-node-id\"")
298+
.SetName("LoadInvalidRoleIdReturnsEmptyDatabaseForInvalidString");
299+
yield return new TestCaseData("42")
300+
.SetName("LoadInvalidRoleIdReturnsEmptyDatabaseForUnexpectedToken");
301+
yield return new TestCaseData(
302+
"""
303+
{
304+
"serverIndex": 0,
305+
"isNull": false,
306+
"isAbsolute": true,
307+
"namespaceIndex": 0,
308+
"idType": "numeric",
309+
"identifier": 1
310+
}
311+
""")
312+
.SetName("LoadInvalidRoleIdReturnsEmptyDatabaseForInvalidNamespace");
313+
yield return new TestCaseData(
314+
"""
315+
{
316+
"serverIndex": 1,
317+
"isNull": true,
318+
"isAbsolute": true,
319+
"namespaceIndex": 0,
320+
"idType": "numeric",
321+
"identifier": null
322+
}
323+
""")
324+
.SetName("LoadInvalidRoleIdReturnsEmptyDatabaseForInvalidNull");
325+
yield return new TestCaseData(
326+
"""
327+
{
328+
"serverIndex": 0,
329+
"isNull": false,
330+
"isAbsolute": false,
331+
"namespaceIndex": 0,
332+
"idType": "unknown",
333+
"identifier": 1
334+
}
335+
""")
336+
.SetName("LoadInvalidRoleIdReturnsEmptyDatabaseForUnknownIdentifierType");
337+
yield return new TestCaseData(
338+
"""
339+
{
340+
"serverIndex": 0,
341+
"isNull": false,
342+
"isAbsolute": false,
343+
"namespaceIndex": 0,
344+
"idType": null,
345+
"identifier": 1
346+
}
347+
""")
348+
.SetName("LoadInvalidRoleIdReturnsEmptyDatabaseForMissingIdentifierType");
349+
yield return new TestCaseData(
350+
"""
351+
{
352+
"serverIndex": 0,
353+
"isNull": false,
354+
"isAbsolute": false,
355+
"namespaceIndex": 0,
356+
"idType": "string",
357+
"identifier": null
358+
}
359+
""")
360+
.SetName("LoadInvalidRoleIdReturnsEmptyDatabaseForNullStringIdentifier");
361+
yield return new TestCaseData(
362+
"""
363+
{
364+
"serverIndex": 0,
365+
"isNull": false,
366+
"isAbsolute": false,
367+
"namespaceIndex": 0,
368+
"idType": "opaque",
369+
"identifier": {
370+
"memory": "not-base64"
371+
}
372+
}
373+
""")
374+
.SetName("LoadInvalidRoleIdReturnsEmptyDatabaseForInvalidOpaqueIdentifier");
375+
yield return new TestCaseData(
376+
"""
377+
{
378+
"serverIndex": 0,
379+
"isNull": false,
380+
"isAbsolute": false,
381+
"namespaceIndex": 0,
382+
"idType": "opaque",
383+
"identifier": {
384+
"memory": null
385+
}
386+
}
387+
""")
388+
.SetName("LoadInvalidRoleIdReturnsEmptyDatabaseForNullOpaqueIdentifier");
389+
}
390+
391+
private static JsonUserDatabase LoadDatabase(string json)
392+
{
393+
string fileName = CreateDatabasePath();
394+
File.WriteAllText(fileName, json);
395+
return (JsonUserDatabase)JsonUserDatabase.Load(
396+
fileName,
397+
NUnitTelemetryContext.Create());
398+
}
399+
400+
private static string CreateDatabaseJson(string roleIdJson)
401+
{
402+
return $$"""
403+
{
404+
"users": [
405+
{
406+
"Id": "5ffc097e-566f-4e98-9a45-fa662826a6aa",
407+
"UserName": "alice",
408+
"Hash": "100000.fBXDf\u002BXgck10FwkXXEVetxGpOsM=.83/yGZTA1roMWqRsDO6TiuWllGOHfipTm01UaTduYJo=",
409+
"Roles": [
410+
{
411+
"Name": "Custom",
412+
"RoleId": {{roleIdJson}}
413+
}
414+
]
415+
}
416+
]
417+
}
418+
""";
419+
}
420+
178421
private static string CreateDatabasePath()
179422
{
180423
string directory = Path.Combine(TestContext.CurrentContext.WorkDirectory, "JsonUserDatabaseTests");

0 commit comments

Comments
 (0)