Files
UA-.NETStandard/tests/Opc.Ua.Gds.Tests/LinqApplicationsDatabaseTests.cs
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# Description

Nullable reference types are now enabled once, in `common.props`, and
every project in the repository builds warning-clean with them.

**Configuration**
- `<Nullable>enable</Nullable>` lives in `common.props`. All per-project
`<Nullable>` settings are removed: 178 project files (enable,
annotations, and the `disable` in the three Aot test projects).
- 419 per-file `#nullable enable`/`restore` directives and the 4
`#nullable disable` directives are removed.
- Kept on purpose:
- The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type
Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`,
`PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the
project setting in generated code, so the directive is required there.
- `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against
the 1.5.378 packages and does not import the repository props.
- `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now
describe the central setting.

**Migration** (the 43 unset and 13 annotations-only projects, roughly
10k diagnostics)
- **Production code: annotated at the declarations.** Covers
`tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the
LDS sample.
- Members, parameters and returns are nullable where null actually
flows.
- Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`,
`IsMethodTypeDesign`).
- `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take
`params object?[]`. `Template.AddReplacement` takes `string?` (rendering
already skips null replacements).
- `IRegisteredServerStore.RegisterAsync` returns
`Task<RegistrationEntry?>`: it returns null when a registration is
removed.
- Dereferences backed by an invariant (for example, the validated model)
use `!`, so runtime behaviour is unchanged.
- **Behavioural changes in production code** (small, deliberate):
- `NodeSetToModelDesign` throws `InvalidOperationException` when the
file is not a NodeSet. Before, it threw a NullReferenceException on the
next line.
- `ModelDesignValidator` starts its lookup tables empty instead of null.
A redundant `if (m_nodes == null) return;` guard is gone.
- **Test code: minimal annotations.**
  - `?` on values that can be null, `!` where the test asserts non-null.
  - `= null!` for fixture members assigned in SetUp.
- `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the
`IEncodeable` constraint.
- A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`)
folded into the adjacent `IsNull`/`Count` checks.

**Notes for reviewers**
- The diff is large (~1,500 files) but almost entirely mechanical: `?`,
`!` and directive removal. The non-annotation changes are listed above.
- Known follow-up, not done here:
`IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay
non-nullable even though the validator returns null before it loads a
dictionary. Making them nullable would ripple through every generator
call site.

## Related Issues

- Fixes #4439

## Checklist

- [ ] 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.
- [ ] 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.
- [ ] I have addressed **all** PR feedback received.

The "no new warnings" box covers net10.0 and net48: full non-incremental
builds of `UA.slnx`, `tools/SourceGeneration.slnx`,
`tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0
warnings and 0 errors under the repository's warnings-as-errors
settings. net9.0 and net8.0 also had no nullable diagnostics, but they
were not rebuilt after the final fixes. Tests have not been run locally
yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:48:13 +02:00

622 lines
29 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.IO;
using System.Linq;
using System.Text.Json.Nodes;
using NUnit.Framework;
using Opc.Ua.Gds.Server;
using Opc.Ua.Gds.Server.Database.Linq;
namespace Opc.Ua.Gds.Tests
{
[TestFixture]
[Category("GDS")]
[SetCulture("en-us")]
[SetUICulture("en-us")]
[Parallelizable]
public class LinqApplicationsDatabaseTests
{
[Test]
public void RegisterApplicationWithDuplicateUriThrowsBadEntryExists()
{
var database = new LinqApplicationsDatabase();
ApplicationRecordDataType application = CreateServerApplication("urn:test:duplicate", "ServerOne");
database.RegisterApplication(application);
Assert.That(
() => database.RegisterApplication(CreateServerApplication("urn:test:duplicate", "ServerTwo")),
Throws.TypeOf<ServiceResultException>()
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadEntryExists));
}
[Test]
public void UpdateApplicationWithChangedUriThrowsBadWriteNotSupported()
{
var database = new LinqApplicationsDatabase();
ApplicationRecordDataType application = CreateServerApplication("urn:test:update-original", "ServerOne");
NodeId applicationId = database.RegisterApplication(application);
ApplicationRecordDataType updatedApplication = CreateServerApplication("urn:test:update-new", "ServerOneUpdated");
updatedApplication.ApplicationId = applicationId;
Assert.That(
() => database.UpdateApplication(updatedApplication),
Throws.TypeOf<ServiceResultException>()
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadWriteNotSupported));
}
[Test]
public void QueryServersStartingRecordIdReturnsStrictlyGreaterRecords()
{
var database = new LinqApplicationsDatabase();
database.RegisterApplication(CreateServerApplication("urn:test:server-1", "ServerOne"));
database.RegisterApplication(CreateServerApplication("urn:test:server-2", "ServerTwo"));
ServerOnNetwork[] allServers = database.QueryServers(0, 0, null!, null!, null!, [], out _);
uint firstRecordId = allServers.Min(server => server.RecordId);
ServerOnNetwork[] pagedServers = database.QueryServers(firstRecordId, 0, null!, null!, null!, [], out _);
Assert.That(pagedServers, Has.Length.GreaterThan(0));
Assert.That(pagedServers.All(server => server.RecordId > firstRecordId), Is.True);
}
/// <summary>
/// OPC 10000-12 §6.5.11: QueryServers returns one record per DiscoveryUrl,
/// and every record needs its own RecordId. With the application id as
/// RecordId, paging with StartingRecordId skipped the remaining
/// DiscoveryUrls of an application once a page ended inside it
/// (CTT GDS Application Directory 045.js, 075.js).
/// </summary>
[TestCase(1u)]
[TestCase(2u)]
[TestCase(3u)]
public void QueryServersPagingReturnsEveryDiscoveryUrlOnce(uint maxRecordsToReturn)
{
var database = new LinqApplicationsDatabase();
database.RegisterApplication(CreateServerApplication("urn:test:server-1", "ServerOne"));
ApplicationRecordDataType multiUrl = CreateServerApplication("urn:test:server-2", "ServerTwo");
multiUrl.DiscoveryUrls = ["opc.tcp://two:1", "opc.tcp://two:2"];
database.RegisterApplication(multiUrl);
ApplicationRecordDataType clientAndServer = CreateServerApplication("urn:test:server-3", "ServerThree");
clientAndServer.ApplicationType = ApplicationType.ClientAndServer;
clientAndServer.DiscoveryUrls = ["opc.tcp://three:1", "opc.tcp://three:2"];
clientAndServer.ServerCapabilities = ["DA", "RCP"];
database.RegisterApplication(clientAndServer);
ServerOnNetwork[] allServers = database.QueryServers(0, 0, null!, null!, null!, [], out _);
Assert.That(allServers, Has.Length.EqualTo(5));
Assert.That(allServers.Select(server => server.RecordId), Is.Unique.And.Ordered);
var paged = new System.Collections.Generic.List<ServerOnNetwork>();
uint startingRecordId = 0;
for (int ii = 0; ii < 10; ii++)
{
ServerOnNetwork[] page = database.QueryServers(
startingRecordId, maxRecordsToReturn, null!, null!, null!, [], out _);
if (page.Length == 0)
{
break;
}
Assert.That(page, Has.Length.LessThanOrEqualTo(maxRecordsToReturn));
Assert.That(page.All(server => server.RecordId > startingRecordId), Is.True);
paged.AddRange(page);
startingRecordId = page[page.Length - 1].RecordId;
}
Assert.That(
paged.Select(server => server.DiscoveryUrl),
Is.EqualTo(allServers.Select(server => server.DiscoveryUrl)));
}
[Test]
public void QueryServersRecordIdsChangeWhenApplicationIsUpdated()
{
var database = new LinqApplicationsDatabase();
ApplicationRecordDataType application = CreateServerApplication("urn:test:server-1", "ServerOne");
NodeId applicationId = database.RegisterApplication(application);
uint recordId = database.QueryServers(0, 0, null!, null!, null!, [], out _).Single().RecordId;
application.ApplicationId = applicationId;
application.DiscoveryUrls = ["opc.tcp://updated:4840"];
database.UpdateApplication(application);
ServerOnNetwork updated = database.QueryServers(0, 0, null!, null!, null!, [], out _).Single();
Assert.That(updated.DiscoveryUrl, Is.EqualTo("opc.tcp://updated:4840"));
Assert.That(updated.RecordId, Is.GreaterThan(recordId));
}
[Test]
public void QueryServersRecordIdsSurviveJsonReloadAndLegacyDatabases()
{
string fileName = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json");
try
{
var database = JsonApplicationsDatabase.Load(fileName);
ApplicationRecordDataType multiUrl = CreateServerApplication("urn:test:server-1", "ServerOne");
multiUrl.DiscoveryUrls = ["opc.tcp://one:1", "opc.tcp://one:2"];
database.RegisterApplication(multiUrl);
database.RegisterApplication(CreateServerApplication("urn:test:server-2", "ServerTwo"));
uint[] recordIds = [.. database.QueryServers(0, 0, null!, null!, null!, [], out _)
.Select(server => server.RecordId)];
Assert.That(recordIds, Is.Unique.And.Ordered);
// Reload: identifiers are persisted and new ones continue above them.
database = JsonApplicationsDatabase.Load(fileName);
Assert.That(
database.QueryServers(0, 0, null!, null!, null!, [], out _).Select(server => server.RecordId),
Is.EqualTo(recordIds));
database.RegisterApplication(CreateServerApplication("urn:test:server-3", "ServerThree"));
Assert.That(
database.QueryServers(recordIds[recordIds.Length - 1], 0, null!, null!, null!, [], out _).Single().DiscoveryUrl,
Is.EqualTo("opc.tcp://localhost:4840/ServerThree"));
// A database saved before endpoints had an identifier.
var json = JsonNode.Parse(File.ReadAllText(fileName))!.AsObject();
json.Remove("LastServerEndpointId");
foreach (JsonNode? endpoint in json["ServerEndpoints"]!.AsArray())
{
endpoint!.AsObject().Remove("ID");
}
File.WriteAllText(fileName, json.ToJsonString());
// Loading migrates and persists the identifiers, without a query or a write.
database = JsonApplicationsDatabase.Load(fileName);
var migrated = JsonNode.Parse(File.ReadAllText(fileName))!.AsObject();
Assert.That((uint)migrated["LastServerEndpointId"]!, Is.EqualTo(4u));
Assert.That(
migrated["ServerEndpoints"]!.AsArray().Select(endpoint => (uint)endpoint!["ID"]!),
Is.Unique.And.All.Not.Zero);
ServerOnNetwork[] legacy = database.QueryServers(0, 1, null!, null!, null!, [], out _);
Assert.That(legacy, Has.Length.EqualTo(1));
Assert.That(legacy[0].RecordId, Is.Not.Zero);
uint[] legacyIds = [.. database.QueryServers(0, 0, null!, null!, null!, [], out _)
.Select(server => server.RecordId)];
Assert.That(legacyIds, Has.Length.EqualTo(4));
Assert.That(legacyIds, Is.Unique.And.Ordered);
}
finally
{
File.Delete(fileName);
}
}
/// <summary>
/// The CTT GDS Application Directory / Query Applications filters used
/// to return no or all records (066.js, 068.js, 071.js, 073.js; Query
/// Applications 013.js, 017.js, 019.js, 022.js, 024.js).
/// </summary>
[TestCase("%_erver%", "", 5, 3)]
[TestCase("%\\_%", "", 2, 1)]
[TestCase("%[q-s]", "", 3, 2)]
[TestCase("%[^q-s]", "", 2, 2)]
[TestCase("%e_", "", 3, 2)]
[TestCase("", "%_ompliance%", 3, 3)]
[TestCase("", "%e_", 3, 2)]
[TestCase("", "%\\_%", 2, 1)]
[TestCase("", "%[q-s]", 3, 2)]
[TestCase("", "%[^q-s]", 2, 2)]
[TestCase("", "[_]", 0, 0)]
public void QueryFiltersUseLikePatterns(
string applicationUri,
string applicationName,
int expectedServers,
int expectedApplications)
{
LinqApplicationsDatabase database = CreateConformanceTestDatabase();
ServerOnNetwork[] servers = database.QueryServers(
0, 0, applicationName, applicationUri, null!, [], out _);
ApplicationDescription[] applications = database.QueryApplications(
0, 0, applicationName, applicationUri, 0, null!, [], out _, out _);
Assert.That(servers, Has.Length.EqualTo(expectedServers));
Assert.That(applications, Has.Length.EqualTo(expectedApplications));
}
[TestCase(uint.MaxValue)]
[TestCase((uint)int.MaxValue + 1)]
public void QueryApplicationsStartingRecordIdAboveInt32ReturnsNothing(uint startingRecordId)
{
LinqApplicationsDatabase database = CreateConformanceTestDatabase();
ApplicationDescription[] applications = database.QueryApplications(
startingRecordId, 0, null!, null!, 0, null!, [], out _, out uint nextRecordId);
Assert.That(applications, Is.Empty);
Assert.That(nextRecordId, Is.Zero);
}
[TestCase("")]
[TestCase(" ")]
public void FindApplicationsWithEmptyUriReturnsNoRecord(string applicationUri)
{
LinqApplicationsDatabase database = CreateConformanceTestDatabase();
Assert.That(database.FindApplications(applicationUri), Is.Empty);
}
/// <summary>
/// OPC 10000-12 §7.9.5: FinishRequest returns Bad_InvalidArgument when
/// the RequestId does not reference a request of the application.
/// </summary>
[Test]
public void FinishRequestOfAnotherApplicationThrowsBadInvalidArgument()
{
var database = new LinqApplicationsDatabase();
NodeId applicationA = database.RegisterApplication(CreateServerApplication("urn:test:a", "ServerA"));
NodeId applicationB = database.RegisterApplication(CreateServerApplication("urn:test:b", "ServerB"));
NodeId requestB = database.StartSigningRequest(
applicationB, "DefaultApplicationGroup", "RsaSha256ApplicationCertificateType",
ByteString.From([1, 2, 3]), "admin");
database.ApproveRequest(requestB, false);
Assert.That(
() => database.FinishRequest(applicationA, requestB, out _, out _, out _, out _),
Throws.TypeOf<ServiceResultException>()
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadInvalidArgument));
Assert.That(
() => database.ReadRequest(applicationA, requestB, out _, out _, out _, out _, out _, out _, out _),
Throws.TypeOf<ServiceResultException>()
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadInvalidArgument));
Assert.That(
database.FinishRequest(applicationB, requestB, out _, out _, out _, out _),
Is.EqualTo(CertificateRequestState.Approved));
}
[Test]
public void StartRequestForAnotherGroupKeepsFirstRequestPending()
{
var database = new LinqApplicationsDatabase();
NodeId application = database.RegisterApplication(CreateServerApplication("urn:test:a", "ServerA"));
NodeId rsaRequest = database.StartSigningRequest(
application, "DefaultApplicationGroup", "RsaSha256ApplicationCertificateType",
ByteString.From([1, 2, 3]), "admin");
NodeId httpsRequest = database.StartSigningRequest(
application, "DefaultHttpsGroup", "HttpsCertificateType",
ByteString.From([4, 5, 6]), "admin");
Assert.That(httpsRequest, Is.Not.EqualTo(rsaRequest));
database.ApproveRequest(rsaRequest, false);
Assert.That(
database.FinishRequest(application, rsaRequest, out string? groupId, out string? typeId, out _, out _),
Is.EqualTo(CertificateRequestState.Approved));
Assert.That(groupId, Is.EqualTo("DefaultApplicationGroup"));
Assert.That(typeId, Is.EqualTo("RsaSha256ApplicationCertificateType"));
Assert.That(
database.FinishRequest(application, httpsRequest, out _, out _, out _, out _),
Is.EqualTo(CertificateRequestState.New));
// A new request for the same group and type supersedes the old one.
NodeId renewedRequest = database.StartSigningRequest(
application, "DefaultApplicationGroup", "RsaSha256ApplicationCertificateType",
ByteString.From([7, 8, 9]), "admin");
Assert.That(renewedRequest, Is.Not.EqualTo(rsaRequest));
Assert.That(
() => database.FinishRequest(application, rsaRequest, out _, out _, out _, out _),
Throws.TypeOf<ServiceResultException>());
}
[Test]
public void ApplicationCertificatesSurviveJsonReload()
{
string fileName = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json");
try
{
var database = JsonApplicationsDatabase.Load(fileName);
NodeId application = database.RegisterApplication(CreateServerApplication("urn:test:a", "ServerA"));
database.SetApplicationCertificate(
application, "RsaSha256ApplicationCertificateType", ByteString.From([1, 2, 3]));
database.SetApplicationTrustLists(
application, "RsaSha256ApplicationCertificateType", "pki/trusted");
database = JsonApplicationsDatabase.Load(fileName);
Assert.That(
database.GetApplicationCertificate(
application, "RsaSha256ApplicationCertificateType", out ByteString certificate),
Is.True);
Assert.That(certificate.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 }));
Assert.That(
database.GetApplicationTrustLists(
application, "RsaSha256ApplicationCertificateType", out string? trustListId),
Is.True);
Assert.That(trustListId, Is.EqualTo("pki/trusted"));
}
finally
{
File.Delete(fileName);
}
}
[Test]
public void LoadOfCorruptDatabaseThrowsAndKeepsTheFile()
{
string fileName = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json");
try
{
const string corrupt = "{ \"Applications\": [ { \"ApplicationUri\": ";
File.WriteAllText(fileName, corrupt);
Assert.That(() => JsonApplicationsDatabase.Load(fileName), Throws.TypeOf<InvalidDataException>());
Assert.That(File.ReadAllText(fileName), Is.EqualTo(corrupt));
// An empty file is an empty database.
File.WriteAllText(fileName, string.Empty);
Assert.That(JsonApplicationsDatabase.Load(fileName).FindApplications("urn:test:a"), Is.Empty);
}
finally
{
File.Delete(fileName);
}
}
/// <summary>
/// A database file that cannot be replaced by another file (on Unix a
/// single-file bind mount; here a handle without delete sharing) is
/// updated in place.
/// </summary>
[Test]
public void SaveUpdatesDatabaseFileThatCannotBeReplaced()
{
string fileName = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json");
try
{
var database = JsonApplicationsDatabase.Load(fileName);
database.RegisterApplication(CreateServerApplication("urn:test:a", "ServerA"));
using (new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
database.RegisterApplication(CreateServerApplication("urn:test:b", "ServerB"));
}
database = JsonApplicationsDatabase.Load(fileName);
Assert.That(database.FindApplications("urn:test:a"), Has.Length.EqualTo(1));
Assert.That(database.FindApplications("urn:test:b"), Has.Length.EqualTo(1));
Assert.That(File.Exists(fileName + ".tmp"), Is.False);
}
finally
{
File.Delete(fileName);
File.Delete(fileName + ".tmp");
}
}
#if NET8_0_OR_GREATER // .NET Framework has no link/mode APIs
/// <summary>
/// A database file that is a symbolic link keeps being a link: the
/// save updates the link target.
/// </summary>
[Test]
public void SaveThroughSymbolicLinkUpdatesTheTarget()
{
string target = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json");
string link = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json");
try
{
File.WriteAllText(target, string.Empty);
try
{
File.CreateSymbolicLink(link, target);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Assert.Ignore("Creating a symbolic link is not permitted: " + ex.Message);
}
var database = JsonApplicationsDatabase.Load(link);
database.RegisterApplication(CreateServerApplication("urn:test:a", "ServerA"));
Assert.That(new FileInfo(link).LinkTarget, Is.Not.Null);
Assert.That(
JsonApplicationsDatabase.Load(target).FindApplications("urn:test:a"),
Has.Length.EqualTo(1));
}
finally
{
File.Delete(link);
File.Delete(target);
}
}
/// <summary>
/// The save keeps the file mode an administrator set on the database.
/// </summary>
[Test]
public void SaveKeepsUnixFileMode()
{
if (OperatingSystem.IsWindows())
{
Assert.Ignore("Unix file modes do not exist on Windows.");
return;
}
string fileName = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json");
try
{
File.WriteAllText(fileName, string.Empty);
const UnixFileMode mode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
File.SetUnixFileMode(fileName, mode);
var database = JsonApplicationsDatabase.Load(fileName);
database.RegisterApplication(CreateServerApplication("urn:test:a", "ServerA"));
Assert.That(File.GetUnixFileMode(fileName), Is.EqualTo(mode));
}
finally
{
File.Delete(fileName);
}
}
#endif
/// <summary>
/// OPC 10000-12 §7.9.4: the private key password shall not be persisted.
/// </summary>
[Test]
public void NewKeyPairRequestPasswordIsNotPersisted()
{
string fileName = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json");
try
{
var database = JsonApplicationsDatabase.Load(fileName);
NodeId application = database.RegisterApplication(CreateServerApplication("urn:test:a", "ServerA"));
NodeId request = database.StartNewKeyPairRequest(
application, "DefaultApplicationGroup", "RsaSha256ApplicationCertificateType",
"CN=ServerA", ["localhost"], "PFX", "secret-password".AsSpan(), "admin");
database.ApproveRequest(request, false);
Assert.That(File.ReadAllText(fileName), Does.Not.Contain("secret-password"));
Assert.That(
database.ReadRequest(application, request, out _, out _, out _, out _, out _, out _,
out ReadOnlySpan<char> password),
Is.EqualTo(CertificateRequestState.Approved));
Assert.That(password.ToString(), Is.EqualTo("secret-password"));
// After a reload the password is gone, so the request cannot be
// completed with an unprotected private key.
database = JsonApplicationsDatabase.Load(fileName);
Assert.That(
database.FinishRequest(application, request, out _, out _, out _, out _),
Is.EqualTo(CertificateRequestState.Rejected));
}
finally
{
File.Delete(fileName);
}
}
/// <summary>
/// A request persisted with its password by an earlier version keeps the
/// password across the upgrade (in memory only) instead of completing with
/// an unprotected key, and the next save no longer writes it.
/// </summary>
[Test]
public void LegacyPersistedPrivateKeyPasswordIsRestoredAndNoLongerWritten()
{
string fileName = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".json");
try
{
var database = JsonApplicationsDatabase.Load(fileName);
NodeId application = database.RegisterApplication(CreateServerApplication("urn:test:a", "ServerA"));
NodeId request = database.StartNewKeyPairRequest(
application, "DefaultApplicationGroup", "RsaSha256ApplicationCertificateType",
"CN=ServerA", ["localhost"], "PFX", "secret-password".AsSpan(), "admin");
database.ApproveRequest(request, false);
// Rewrite the file the way earlier versions stored the request.
var json = JsonNode.Parse(File.ReadAllText(fileName))!.AsObject();
JsonObject stored = json["CertificateRequests"]!.AsArray()[0]!.AsObject();
stored.Remove("HasPrivateKeyPassword");
var legacyPassword = new JsonArray();
foreach (char c in "secret-password")
{
legacyPassword.Add(c.ToString());
}
stored["PrivateKeyPassword"] = legacyPassword;
File.WriteAllText(fileName, json.ToJsonString());
database = JsonApplicationsDatabase.Load(fileName);
Assert.That(
database.ReadRequest(application, request, out _, out _, out _, out _, out _, out _,
out ReadOnlySpan<char> password),
Is.EqualTo(CertificateRequestState.Approved));
Assert.That(password.ToString(), Is.EqualTo("secret-password"));
database.RegisterApplication(CreateServerApplication("urn:test:b", "ServerB"));
Assert.That(File.ReadAllText(fileName), Does.Not.Contain("\"PrivateKeyPassword\""));
}
finally
{
File.Delete(fileName);
}
}
/// <summary>
/// The applications the CTT GDS Application Directory and Query
/// Applications units register (ServerCapabilities simplified).
/// </summary>
private static LinqApplicationsDatabase CreateConformanceTestDatabase()
{
var database = new LinqApplicationsDatabase();
database.RegisterApplication(new ApplicationRecordDataType
{
ApplicationUri = "urn:OPCFoundation:ComplianceTestTool",
ApplicationType = ApplicationType.Client,
ApplicationNames = [new LocalizedText("en-US", "OPC Foundation Compliance Test Tool")],
ProductUri = "urn:opcfoundation/uactt",
ServerCapabilities = ["RCP"]
});
database.RegisterApplication(new ApplicationRecordDataType
{
ApplicationUri = "urn:OPCFoundation:ComplianceTestToolEmbeddedServer",
ApplicationType = ApplicationType.Server,
ApplicationNames = [new LocalizedText("en-US", "OPC Foundation Compliance Test Tool - Embedded Server")],
ProductUri = "urn:opcfoundation/uactt_embedded_server",
DiscoveryUrls = ["opc.tcp://localhost:4842"],
ServerCapabilities = ["NA"]
});
database.RegisterApplication(new ApplicationRecordDataType
{
ApplicationUri = "urn:OPCFoundation:ServerApplication",
ApplicationType = ApplicationType.Server,
ApplicationNames = [new LocalizedText("en-US", "OPC Foundation Compliance Test Tool - Server Application")],
ProductUri = "urn:opcfoundation/uactt_server_application",
DiscoveryUrls = ["opc.tcp://ServerApplication:12345", "opc.tcp://ServerApplication:12346"],
ServerCapabilities = ["DA"]
});
database.RegisterApplication(new ApplicationRecordDataType
{
ApplicationUri = "cab:other_foundation:ClientAndServer",
ApplicationType = ApplicationType.ClientAndServer,
ApplicationNames = [new LocalizedText("en-US", "Example_Vendor - ClientAndServer")],
ProductUri = "cab:some_name/client_and_server",
DiscoveryUrls = ["opc.tcp://ClientAndServer:12345", "opc.tcp://ClientAndServer:12346"],
ServerCapabilities = ["AC", "DA", "RCP", "HD"]
});
return database;
}
private static ApplicationRecordDataType CreateServerApplication(string applicationUri, string name)
{
return new ApplicationRecordDataType
{
ApplicationUri = applicationUri,
ApplicationType = ApplicationType.Server,
ApplicationNames = [new LocalizedText("en", name)],
ProductUri = "urn:test:product",
DiscoveryUrls = [$"opc.tcp://localhost:4840/{name}"],
ServerCapabilities = ["LDS"]
};
}
}
}