mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-28 22:55:49 +08:00
## Scope Audit fixes and review follow-ups across the OPC UA Core, Client, Server, transport, and redundancy components. This includes implemented Tier 1, Tier 2, and Tier 3 work; it does not claim that every item in the 244-finding audit backlog is complete. The review batch fixes mandatory full-key record protection and owned plaintext buffers; transactional pending-certificate claims; shared-channel nonce and certificate ownership; discovery and token-policy validation; reconnect/callback ordering without changing unbounded buffering defaults; server permissions, filters, monitored-item lifetime, and alarm topology; historian paging, retention, and annotations; atomic user/password metadata; and canonical file identities with bounded alias and role refresh workers. ## Review-batch verification at `ab686ccae` The following results come from fresh test builds using `dotnet test`, `--no-restore`, `-m:1`, and an explicit `CustomTestTarget`. They replace the earlier description's library-build/`--no-build` totals, which did not establish the state of the merged test sources. | Scope | Target | Passed | Skipped | |---|---|---:|---:| | Core review regressions | net10.0 | 827 | 0 | | Client and lifecycle regressions | net10.0 | 630 | 0 | | Server review regressions | net10.0 | 1970 | 0 | | Redundancy Server / Client / PubSub | net10.0 | 276 / 10 / 11 | 0 | | Sessions / file-and-alias / streaming / HTTPS-WSS integration | net10.0 | 8 / 21 / 30 / 20 | 0 | | Core review regressions | net48 | 786 | 39 | | Client recovery and policy regressions | net48 | 54 | 0 | | Server ownership, aliases, queues, and user-state regressions | net48 | 137 | 0 | Every listed run exited successfully. The 39 net48 skips are existing platform/capability cases. New key-agreement tests assert unavailable-policy behavior on unsupported targets rather than adding skips or pretending those targets performed an unsupported handshake. Additional exact node/permission/filter/alarm checks passed 13 Core and 46 Server cases. The PR-scoped formatter verification covers 26 projects and 389 non-generated C# files. A Roslyn syntax audit covers 53 changed interfaces and 263 members, with no missing parameter/type-parameter/return contracts and a 120-column limit for their declarations and documentation. No full-solution, all-target-framework, or NativeAOT matrix is claimed from these local runs; the new CI run must establish its own result. ## CI follow-up at `e1bc3ac3b` The follow-up fixes the failures reported by Azure build 18400. TLS certificates retain configured DNS names, including mixed-case URI schemes, without relaxing hostname validation. Reverse-WSS fixtures use the DNS identity in the client certificate. Reconnect completion is published only after cycle cleanup, so a subsequent reconnect cannot join an old completed cycle and retain old certificates. Certificate-alarm roots now support event subscriptions. The pump sample has one continuous notifier path, with no duplicate shortcut. Historian and aggregate fixtures share the provider's retention clock. Alarm collection lets the server expire idle Publish requests rather than canceling only the client wait. The corrected server, WoT, and managed-session fixtures exercise valid requests and initialized lifecycle contracts. Downlevel crypto tests preserve real credential and key-ownership assertions without enabling unsupported raw-ECDH policies or adding skips. Fresh local evidence after integration includes: | Scope | Target | Passed | Skipped | |---|---|---:|---:| | Client lifecycle / channel ownership / builder fixtures | net10.0 and net48, each | 96 / 103 / 123 | 0 | | Combined server lifecycle, events, aggregates, crypto, and contracts | net48 | 358 | 0 | | Full lifecycle plus certificate-alarm and aggregate fixtures | net10.0 | 116 | 0 | | Final server-contract and history-routing delta | net10.0 and net48, each | 84 | 0 | | Subscription / XRegistry / WoT event regressions | net10.0 | 7 / 2 / 3 | 0 | | Full Sessions project | net10.0 | 780 | 379 | | Full ComplexTypes project / downlevel resolver fixtures | net10.0 / net48 | 2854 / 15 | 0 | | Full WoT project / downlevel structured-write fixtures | net10.0 / net48 | 1496 / 27 | 0 | | TLS domain-name regressions | net10.0 and net48, each | 21 | 0 | All listed runs exited 0. The Sessions skips come from existing endpoint and policy capability guards; they are not counted as passes. No skip was added. Worker validation also covered full DI (426 passes), full History (540 passes, 21 existing skips), 38 exact downlevel event/history failures, and 60 repeated certificate-rotation race cases. The final crypto fixtures passed 73 cases on each target with the unchanged leak detector; a temporary premature-disposal mutation made all three borrower tests fail and was then removed. All 29 C# files in this follow-up pass included-file formatting at info severity across 12 projects. The complete PR interface audit now covers 54 interfaces and 271 members with zero missing contracts or line-width findings. No full-solution or NativeAOT matrix is claimed. New-head CI has started and remains authoritative for the remote result. ## Test-result gate follow-up at `4b6e1203a` Azure build 18401 reported the net48 Client job as successful despite a test-host crash after only 264 passing tests. Its old evaluator counted per-test failures but ignored the failed run verdict. This was a partial run, not a benign exit after the suite had completed. The shared result evaluator now rejects aborted, errored, incomplete, malformed, or inconsistent TRX reports. Each file must describe a completed run, so a passing file cannot mask another file's failure. It retains support for completed runs followed by a benign nonzero host exit, existing skipped-result conventions, and no-TRX MTP jobs. All 78 regression cases pass after integration using Windows PowerShell 5.1. Real completed Sessions and Client reports also pass. The process-test fixture follows the existing NET10_0-only tools-test pattern; its net48 build is not counted as a test run. Formatting passes for the added test file. The Client host crash itself has not reproduced locally. The isolated fixture passed 59 tests, and two fresh full fast-CI selections each completed with 2,392 passes and three existing skips, including a run with crash collection enabled. The CI merge tree is identical to the tested source tree. No speculative runtime change was made, and the original partial CI run is not claimed as a full-suite pass. Azure build 18402 has now completed the full net48 Client run: 2,392 passed, three existing console skips, and a successful test-host exit. The corrected evaluator independently validated outcome=Completed. Across the matrix, 119 test-project jobs succeeded; Sessions net10.0 was canceled when the remote provider deprovisioned its runner, so the overall Azure pipeline was canceled. That cancellation is not claimed as a code failure or a successful run. CodeQL and three Docker image jobs were still running at the latest check. The migration discovery failure remains separate. ## Documentation follow-up at `57ed02bea` The environment blocker has been removed by the maintainer. The three bundled migration references now exactly match the canonical documents after deterministic link rewriting; all 15 bundled documents pass `validate-migration-plugin.ps1`. The canonical migration prose was not rewritten. `FileSystemClient.md` now documents that extended Windows roots preserve literal trailing-dot names, completing the filesystem review follow-up. The filesystem thread has been replied to and resolved after publication. The review pass now has 108 of 110 threads resolved. The two explanation-only threads remain open as requested; no unfinished change request is being represented as resolved. ## Cross-platform follow-up at `0d9d936a8` With migration discovery restored, the Linux and macOS test matrix exposed two stale durable-subscription expectations. The expected value 83,442 encoded the old 32-bit overflow when converting 3,600 hours to milliseconds. The existing overflow-safe server correctly returns 14,400,000 lifetime intervals at a 900 ms publishing interval. Only those two expected constants changed; production behavior, negotiated-hour checks, and all other assertions remain unchanged. The exact local lifetime selection reproduced both failures before the correction. The full durable suite now passes 36 cases with 11 existing skips on each of net10.0 and net48. All four lifetime cases executed and passed on both targets, and included-file info-severity formatting passes. ## Session shutdown follow-up at `8daf7f091` A late net48 Sessions failure exposed a shutdown race. Canceling an in-flight keepalive read can produce a transport `ServiceResultException`. The retiring worker treated its own cancellation as proof that the peer was unavailable, so `CloseAsync` or `DisposeAsync` could skip the server-side `CloseSession` request. The worker now ignores that service error only when its own token is canceled; genuine peer errors still update liveness. The full integration fixture reproduced the original error-code mismatch. Four deterministic mock-transport cases then produced three failures and one passing genuine-error control before the fix. They verify successful close, returned server error, asynchronous disposal, and unchanged behavior for a real keepalive failure. Fresh related runs pass 75 client and 101 integration cases on each of net48 and net10.0, with no skips. Ten repetitions of the formerly flaky full net48 fixture pass all 1,010 cases. All three changed files pass info-severity formatting, and temporary diagnostic instrumentation was removed. ## Atomic listener snapshot follow-up at `82dc22b5d` Ubuntu Core CI exposed an unsafe formatter rewrite in TCP listener disposal: `ConcurrentDictionary.ToArray()` had become a collection spread. A concurrent channel removal between the spread's count and copy operations could leave a default entry, causing a null dereference during disposal. The listener now uses the dictionary's atomic `Values` snapshot and disposes those channels outside the listener lock. Deterministic regressions close zero, one, or all channels at the copy boundary. Before the fix, the one/all-removal cases reproduced the exact null dereference; the unchanged-count control passed. All 23 admission/listener cases now pass on net10.0 and net48, and the full fast Core net10.0 suite passes 5,267 cases with 88 existing platform skips. Both changed files pass info-severity formatting. A bounded audit of the formatter's other snapshot rewrites found no further verified unsafe concurrent-registry copies. ## Shutdown-test observation follow-up at `58403ee8b` The net48 shutdown-order test raced its own observation: it read `m_publishWorkerTask` after starting shutdown, which can legitimately drain the worker and clear that field immediately. The test now captures the worker before shutdown starts and confirms it is initially running. It still requires the worker to finish while shutdown remains blocked by the held manager semaphore. Production behavior is unchanged. A temporary scheduling perturbation reproduced the exact null-field failure. The corrected capture passed 100 perturbed iterations; both the temporary repeat and yield were then removed. The final 126 subscription/lifecycle cases pass on each of net48 and net10.0, and formatting passes. A complete local Sessions net10.0 run also passed 780 cases with 379 existing capability skips in 7m20s after the earlier keepalive correction. ## Retirement and alias-startup follow-up at `1a048ff39` The shadow-generation test now observes its documented asynchronous cleanup through the existing bounded helper. Removing the replacement does not join a background drain that already claimed the retired generation. The test still checks live-registration removal immediately and requires the old node to disappear; no timeout or production lifecycle behavior changed. All 81 lifecycle cases pass on each of net10.0 and net48. Initial alias-node materialization now retries one timed-out, read-only snapshot query before creating nodes for that root. The retry is logged through source-generated logging. Each query retains the original shared 100 ms deadline; repeated timeouts, other errors, and cancellation still abort startup. Client searches and live refresh do not gain this immediate retry. Fault-injection tests cover success after one timeout, a persistent timeout, a different store failure, and cancellation. All 187 related alias/hosting cases and the original certificate-rotation integration case pass on each target; the positive test records exactly one retry warning. The alias documentation now accurately describes the shared request budget. All changed C# files pass formatter checks, and the complete 54-interface/271-member documentation audit passes. Fresh CI is running on `1a048ff39`; the previous completed matrix exposed only the two failures corrected in this batch and their dependent summaries. ## Sample endpoint allocation follow-up at `d08a94925` The strong-cluster test harness allocated OPC UA and Raft endpoints in separate released-port batches. Uniqueness was enforced only inside each batch, so the same OS-assigned port could appear in both protocol groups. The harness now requests one distinct batch for both groups and splits it before launching any replicas. Deterministic one- and three-replica allocation cases reproduced overlapping groups with the old plan and pass with the corrected plan. The complete 17-case short-haul sample suite passes without skips; all failover, historian-continuation, and service assertions remain unchanged. Both changed files pass info-severity formatting. This prevents intra-cluster self-collisions but does not claim to reserve released ports against unrelated external processes. ## Code-review follow-up at `085fd9636` A review of the complete branch diff found five defects this PR introduced. All are now fixed, each in its own commit. `RelatedTo`'s recursive-to-iterative rewrite defaulted the optional `includeTypeDefinitionSubtypes` and `includeReferenceTypeSubtypes` operands to `false`. Part 4 §7.7.4 specifies both default to `true`, so a filter that omitted them silently stopped matching subtypes. Dynamically created key-credential nodes derived their namespace from the containing folder, which is the standard `i=18155` node in namespace 0 — the namespace reserved for the OPC UA standard address space; they now resolve the server-owned namespace and fail with `BadInternalError` rather than guessing index 1. The retained audience-less `CreateTokenData` overload produced tokens that verification always rejects, so it is now `[Obsolete]` and throws instead. A failed reference to a remote parent left the new node registered with a one-sided inverse reference, and is now rolled back. An existing `.pfx` is restricted before it is rewritten, rather than after its new key bytes are on disk. One further reported finding was rejected after investigation. `JwksIssuerKeyResolver` deliberately does not dispose retired key snapshots: `RetiredKeysAreCollectibleWhileResolverRemainsAliveAsync` proves the resolver retains no retired snapshot, and `ReturnedKeysSurviveRepeatedRefreshAndResolverDisposalAsync` requires already-returned keys to keep verifying across refreshes and resolver disposal. Adding disposal would have broken that tested contract, so the exploratory change was reverted. Validated on net10.0 and net48: 256 Core and 633 Server cases pass, with one pre-existing net48 skip. The build reports zero compiler and analyzer diagnostics, all eight included-file info-format checks pass, the contract audit covers 58 interfaces and 310 members with no findings, and the 15 bundled migration documents validate. All 122 review threads are resolved; the key-credential namespace thread was reopened to retract an earlier incorrect answer and then resolved against the real fix. ## Review-body follow-up at `da0d59cfb` The re-review at `8daf7f091 + 82dc22b5d` listed four items in its summary that had no inline thread of their own, so they had not been picked up. All four are now fixed. Both event-dispatch permission gates had been tightened from not-Bad to `IsGood`, which silently dropped events whose permission verdict is Uncertain; the data-change path in the same file still treated Uncertain as deliverable, so the two had diverged. `EventSourceRegistry` only cleared `ConsecutiveFailures` when a source was deactivated, so a source that had backed off to the 30-second cap kept that delay for the rest of the process even after starting successfully. `UserNameIdentityTokenHandler` built a temporary issuer chain whose `Add` calls take independent certificate references, and never released them. The fourth was the receiver-side sequence rollover, which the reviewer had correctly re-flagged as still open despite its thread being resolved. `m_sequenceRollover` was set permanently, so the second legal wrap on a long-lived channel was rejected and faulted the channel along with the transport-reconnect OPN. OPC 10000-6 does not cap a SecureChannel at one wrap. The flag is now cleared once an accepted number has traversed the normal range, which keeps rejecting a replayed low number that never left the rollover window. Validated on net10.0 and net48: 636 / 593 Core cases and 135 / 134 Server cases pass, including the new `UaSCBinaryChannelSequenceRolloverTests` and the Uncertain-permission delivery cases. The build reports zero compiler and analyzer diagnostics and the changed files pass included-file info-format checks on both targets. The previous head `085fd9636` was fully green: 279 checks passed and Azure build 18414 succeeded with 137 of 137 jobs, after the platform automatically retried a job whose NuGet tool download had hit a transient TLS error. ## Reported issues #4508 and #4509 at `6ad1ecf16` **#4508 (V2 subscription recreate deadlocks against a faulted transport) was not yet fixed and is now closed by `bac260a06`.** Both drain sites the report identifies were still unbounded. A publish worker that started its request before the quiesce parks inside the service call on the channel ready gate while holding an active publish request; the recreate that would make the channel ready is the very operation awaiting the drain, so the wait never ends. The drain now cancels in-flight attempts through a per-attempt token linked to the worker token. An aborted worker rolls its acknowledgements back, releases the request and returns to the paused park instead of terminating, so it resumes when the caller leaves the quiesce. Publish ingress stays quiesced for the caller's operation, so `DropPendingForSubscription` keeps the guarantee the report asks to preserve and no blind timeout is added. The new regression parks a worker on a cancellable gate that never completes on its own: it reproduces the hang for the full 30-second budget before the change and passes after it. **#4509 (identity provider fails the initial connect) was already fixed for its primary symptom.** `HandleConnectAsync` materializes the provider's identity through `CreateIdentitySelectionContext` before the first `CreateAsync`, so Anonymous is no longer substituted against a UserName-only endpoint. `6ad1ecf16` adds the two remaining details from the report: the enabled-policy set now includes the negotiated channel policy, so a client cannot reject the policy it already accepted as `NotEnabledByClient`; and when discovery has not populated the endpoint's token policies the connect keeps the configured identity so the attempt fails at the transport layer for the reconnect policy to retry, instead of failing at the identity layer. Validated on net10.0 and net48: 477 / 473 client session and subscription cases pass, the build is free of compiler and analyzer diagnostics, and the changed files pass included-file info-format checks on both targets. The full `Opc.Ua.Sessions.Tests` suite also passes locally on net10.0 with 782 passed and 379 pre-existing capability skips. GitHub checks are green on this head: Build and Test .NET 10.0, Docker Sample Images CI, and NuGet Publish all succeeded. Azure build 18417 completed 134 of its 135 jobs successfully; its net10.0 Sessions job was cancelled by the hosted-agent provider ("Received request to deprovision"), the same infrastructure flake that hit build 18409 earlier on a head predating these changes. That job is not claimed as passing, and the local full-suite run above covers the same project. The previous head `da0d59cfb` completed Azure build 18415 with every job succeeded. ## Review follow-up at `a08f0c12b` Eleven new review threads arrived, nearly all documentation. Before acting on each "this is new in 2.0" remark I checked the API against `master378` rather than assuming, because a genuine 1.5 API still needs its migration entry. `ManagedSession`'s server-redundancy refresh had two hard-coded two-second bounds. The new `ServerRedundancyOptions` exposes `RefreshTimeout` for the metadata read and `PeerDiscoveryTimeout` for each peer lookup, defaults both to the previous two seconds, validates that a bound is positive or `Timeout.InfiniteTimeSpan`, and flows through `ManagedSessionOptions`, `ManagedSession.CreateAsync`, the fluent builder, and `DefaultServerRedundancyHandler`. `AsyncCustomNodeManager` and `KeyCredentialBridgeAuthenticator` are both absent from `master378`, so their obsolete members had no compatibility constraint and are removed rather than carried: `AsyncCustomNodeManager.ReadInitialValue`, the audience-less `CreateTokenData` that only threw, and the audience-less `CreateProof` that produced a proof no server accepts. `CustomNodeManager2.ReadInitialValue` and the `MonitoredNode2` migration section are genuine 1.5 APIs and are untouched. The documentation now states master's behaviour instead of the delta from 1.5.378, with each moved item already covered under `docs/migrate/2.0.x/`. `IHistorianAnnotationProvider` is described by its annotation-time key rather than as "legacy" — it is new in 2.0, so the label implied a compatibility constraint that does not exist. The KeyCredential bridge payload's `"version"` field is explained as a vendor-extension format version with no OPC UA counterpart, and its version-1 upgrade narrative is gone. `CertificateManager.md`'s migration walkthrough is replaced by the resolver API it targets. `NodeManagers.md`, `Sessions.md`, `AsyncServerSupport.md`, `Certificates.md`, and `Profiles.md` no longer announce features relative to 1.5. `Benchmarks.md`, `WhatsNewIn2.0.md`, `DeveloperGuide.md`, and `docs/migrate/` keep their 1.5.378 references, which are their subject. CI then exposed a defect this batch introduced: five fixtures resolve `ManagedSession`'s private constructor by its exact positional parameter types, so the added parameter compiled cleanly but made `GetConstructor` return `null` at run time. `7df1d5c40` updates every site; `Redundancy.Client` passes 126 of 126 and the affected client fixtures 112 of 112. The BouncyCastle question is answered and tracked in #4510: `Opc.Ua.Core` already project-references `Opc.Ua.Security.Certificates`, which already carries the conditional BouncyCastle reference, so the raw-ECDH polyfill belongs there rather than as a new Core dependency. The `IUserDatabase` metadata question is addressed to another maintainer and is deliberately left open. ## Reported issue #4509 follow-up at `5d4d13d63` The reporter verified `6ad1ecf16` and found one case still failing, correctly. The empty-token-policies branch kept the configured identity so a server that is down surfaces a transport failure rather than an identity error. A provider-only configuration has no configured identity, so it kept `null`: `OpenCoreAsync` turned that into Anonymous and `OpenValidateIdentity` rejected it with `BadIdentityTokenInvalid`, silently ignoring the configured provider. The branch now requires a configured identity to defer to. Without one the provider is consulted, so its own `BadIdentityTokenRejected` names the offered and rejected policy set instead. Two regression tests cover both configurations; the first fails against the previous behaviour by reaching the session factory with a `null` identity, which is the exact step that produced the reported error. That branch had shipped without a test, which is why the case was missed. ## Review and CI status at `5d4d13d63` **All actionable review threads are resolved: 132 of 133.** The single open thread is a question the reviewer addressed to another maintainer about moving user metadata into `IUserDatabase`; it is left open for them rather than resolved silently. Every resolved thread carries a reply naming its fix commit. **Latest-head CI is green on `5d4d13d63`: 279 checks passed, zero failed, zero pending**, with one conditional signed-package publishing job skipped. Local validation for this batch: the full `Opc.Ua.Client.Tests` suite passes 2,430 of 2,430 on net10.0, `Opc.Ua.Redundancy.Client.Tests` 126 of 126, and `Opc.Ua.Server.Tests` 590 of 590 for the affected selections. Both net10.0 and net48 build clean for `Opc.Ua.Client`, `Opc.Ua.Server`, and the changed test projects, and all changed files pass included-file info-severity formatting on both targets. The PR remains open; no merge was performed. As stated at the top, this does not claim that every item in the 244-finding audit backlog is complete. ## Latest CI follow-up at `b6f2dfa43` Azure build 18442 exposed a net48 compile regression in the Kafka adapter: `RuntimeHelpers.GetUninitializedObject` is unavailable on .NET Framework. Commit `2cf1e0eed` now uses normal `ProducerMessage` construction on `NETFRAMEWORK` while retaining reflection-based construction for modern targets that can load a different Dekaf runtime asset. The exact net48 CI selection passes 92 of 92 tests, and the netstandard2.1 adapter guard selection passes 11 of 11. After merging the latest `master`, GitHub Actions run 35643592139 is green. The initial Ubuntu Core.Schema job built successfully and passed 160 of 160 tests, then hit an external 403 while finalizing its test-result artifact; its failed-job rerun completed successfully. Azure build 18451 was canceled when its remote provider deprovisioned the net10.0 Sessions runner (`Received request to deprovision: The request was cancelled by the remote provider`). Azure attached no test task log or source error, and the canceled parent left that child check stale as in-progress. No source workaround, test skip, or check relaxation was made for either infrastructure failure. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 68f26810-22b8-476f-abcd-0a8c82d6e208
1081 lines
39 KiB
C#
1081 lines
39 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.Collections.Generic;
|
|
using System.IO;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Tests;
|
|
|
|
namespace Opc.Ua.Core.Tests
|
|
{
|
|
[TestFixture]
|
|
[SetCulture("en-us")]
|
|
[SetUICulture("en-us")]
|
|
public class ApplicationConfigurationTests
|
|
{
|
|
private ITelemetryContext m_telemetry;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
m_telemetry = NUnitTelemetryContext.Create();
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// Parameterless constructor creates a valid instance.
|
|
/// </summary>
|
|
public void ConstructorDefault()
|
|
{
|
|
var config = new ApplicationConfiguration();
|
|
Assert.That(config, Is.Not.Null);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// Constructor with telemetry creates a valid instance.
|
|
/// </summary>
|
|
public void ConstructorWithTelemetry()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.That(config, Is.Not.Null);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// Copy constructor copies all supported properties.
|
|
/// </summary>
|
|
public void ConstructorCopy()
|
|
{
|
|
var original = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ApplicationName = "TestApp",
|
|
ApplicationUri = "urn:test:app",
|
|
ApplicationType = ApplicationType.Server,
|
|
DisableHiResClock = true
|
|
};
|
|
|
|
var copy = new ApplicationConfiguration(original);
|
|
Assert.That(copy.ApplicationName, Is.EqualTo("TestApp"));
|
|
Assert.That(copy.ApplicationUri, Is.EqualTo("urn:test:app"));
|
|
Assert.That(copy.ApplicationType, Is.EqualTo(ApplicationType.Server));
|
|
Assert.That(copy.DisableHiResClock, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ApplicationName get/set round-trips correctly.
|
|
/// </summary>
|
|
public void ApplicationNameGetSet()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ApplicationName = "MyApp"
|
|
};
|
|
Assert.That(config.ApplicationName, Is.EqualTo("MyApp"));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ApplicationName defaults to null.
|
|
/// </summary>
|
|
public void ApplicationNameDefaultIsNull()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.That(config.ApplicationName, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ApplicationUri get/set round-trips correctly.
|
|
/// </summary>
|
|
public void ApplicationUriGetSet()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ApplicationUri = "urn:test:myapp"
|
|
};
|
|
Assert.That(config.ApplicationUri, Is.EqualTo("urn:test:myapp"));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ApplicationUri defaults to null.
|
|
/// </summary>
|
|
public void ApplicationUriDefaultIsNull()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.That(config.ApplicationUri, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ProductUri get/set round-trips correctly.
|
|
/// </summary>
|
|
public void ProductUriGetSet()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ProductUri = "urn:test:product"
|
|
};
|
|
Assert.That(config.ProductUri, Is.EqualTo("urn:test:product"));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ProductUri defaults to null.
|
|
/// </summary>
|
|
public void ProductUriDefaultIsNull()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.That(config.ProductUri, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ApplicationType get/set round-trips correctly.
|
|
/// </summary>
|
|
public void ApplicationTypeGetSet()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ApplicationType = ApplicationType.ClientAndServer
|
|
};
|
|
Assert.That(config.ApplicationType, Is.EqualTo(ApplicationType.ClientAndServer));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ApplicationType defaults to Server.
|
|
/// </summary>
|
|
public void ApplicationTypeDefaultIsServer()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.That(config.ApplicationType, Is.EqualTo(ApplicationType.Server));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ServerConfiguration get/set round-trips correctly.
|
|
/// </summary>
|
|
public void ServerConfigurationGetSet()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
var serverConfig = new ServerConfiguration();
|
|
config.ServerConfiguration = serverConfig;
|
|
Assert.That(config.ServerConfiguration, Is.SameAs(serverConfig));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ServerConfiguration defaults to null.
|
|
/// </summary>
|
|
public void ServerConfigurationDefaultIsNull()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.That(config.ServerConfiguration, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ClientConfiguration get/set round-trips correctly.
|
|
/// </summary>
|
|
public void ClientConfigurationGetSet()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
var clientConfig = new ClientConfiguration();
|
|
config.ClientConfiguration = clientConfig;
|
|
Assert.That(config.ClientConfiguration, Is.SameAs(clientConfig));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ClientConfiguration defaults to null.
|
|
/// </summary>
|
|
public void ClientConfigurationDefaultIsNull()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.That(config.ClientConfiguration, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// SecurityConfiguration is created by default constructor.
|
|
/// </summary>
|
|
public void SecurityConfigurationDefaultIsNotNull()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.That(config.SecurityConfiguration, Is.Not.Null);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// SecurityConfiguration set with null reverts to default.
|
|
/// </summary>
|
|
public void SecurityConfigurationSetNullReturnsDefault()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
SecurityConfiguration = null
|
|
};
|
|
Assert.That(config.SecurityConfiguration, Is.Not.Null);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// SecurityConfiguration get/set round-trips correctly.
|
|
/// </summary>
|
|
public void SecurityConfigurationGetSet()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
var secConfig = new SecurityConfiguration();
|
|
config.SecurityConfiguration = secConfig;
|
|
Assert.That(config.SecurityConfiguration, Is.SameAs(secConfig));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// TransportQuotas get/set round-trips correctly.
|
|
/// </summary>
|
|
public void TransportQuotasGetSet()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
var quotas = new TransportQuotas();
|
|
config.TransportQuotas = quotas;
|
|
Assert.That(config.TransportQuotas, Is.SameAs(quotas));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// TransportQuotas defaults to null.
|
|
/// </summary>
|
|
public void TransportQuotasDefaultIsNull()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.That(config.TransportQuotas, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// TransportConfigurations is created by default constructor.
|
|
/// </summary>
|
|
public void TransportConfigurationsDefaultIsNotNull()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.That(config.TransportConfigurations.IsNull, Is.False);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// TransportConfigurations get/set round-trips correctly.
|
|
/// </summary>
|
|
public void TransportConfigurationsGetSet()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
TransportConfigurations = new List<TransportConfiguration>
|
|
{
|
|
new("opc.tcp", typeof(object))
|
|
}.ToArrayOf()
|
|
};
|
|
Assert.That(config.TransportConfigurations.Count, Is.EqualTo(1));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// CertificateManager is null by default before validation.
|
|
/// </summary>
|
|
public void CertificateManagerDefaultIsNull()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.That(config.CertificateManager, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// CertificateManager get/set round-trips correctly.
|
|
/// </summary>
|
|
public void CertificateManagerGetSet()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
using var manager = new CertificateManager(m_telemetry);
|
|
config.CertificateManager = manager;
|
|
Assert.That(config.CertificateManager, Is.SameAs(manager));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// TraceConfiguration get/set round-trips correctly.
|
|
/// </summary>
|
|
public void TraceConfigurationGetSet()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
TraceConfiguration = new TraceConfiguration { OutputFilePath = "trace.log" }
|
|
};
|
|
Assert.That(config.TraceConfiguration, Is.Not.Null);
|
|
Assert.That(config.TraceConfiguration.OutputFilePath, Is.EqualTo("trace.log"));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// TraceConfiguration defaults to null.
|
|
/// </summary>
|
|
public void TraceConfigurationDefaultIsNull()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.That(config.TraceConfiguration, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// DisableHiResClock get/set round-trips correctly.
|
|
/// </summary>
|
|
public void DisableHiResClockGetSet()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
DisableHiResClock = true
|
|
};
|
|
Assert.That(config.DisableHiResClock, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// DisableHiResClock defaults to false.
|
|
/// </summary>
|
|
public void DisableHiResClockDefaultIsFalse()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.That(config.DisableHiResClock, Is.False);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// DiscoveryServerConfiguration get/set round-trips correctly.
|
|
/// </summary>
|
|
public void DiscoveryServerConfigurationGetSet()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
var dsc = new DiscoveryServerConfiguration();
|
|
config.DiscoveryServerConfiguration = dsc;
|
|
Assert.That(config.DiscoveryServerConfiguration, Is.SameAs(dsc));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// DiscoveryServerConfiguration defaults to null.
|
|
/// </summary>
|
|
public void DiscoveryServerConfigurationDefaultIsNull()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.That(config.DiscoveryServerConfiguration, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// Properties dictionary is accessible and initially empty.
|
|
/// </summary>
|
|
public void PropertiesDictionaryIsAccessible()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.That(config.Properties, Is.Not.Null);
|
|
Assert.That(config.Properties, Is.Empty);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// GetOrAddProperty runs the factory once when the key is absent.
|
|
/// </summary>
|
|
public void GetOrAddPropertyAddsWhenKeyIsAbsent()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
int calls = 0;
|
|
|
|
string value = config.GetOrAddProperty("key", () =>
|
|
{
|
|
calls++;
|
|
return "made";
|
|
});
|
|
|
|
Assert.That(value, Is.EqualTo("made"));
|
|
Assert.That(calls, Is.EqualTo(1));
|
|
Assert.That(config.Properties["key"], Is.EqualTo("made"));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// GetOrAddProperty does not run the factory when a value is present.
|
|
/// </summary>
|
|
public void GetOrAddPropertyDoesNotRunFactoryWhenKeyIsPresent()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
config.Properties["key"] = "existing";
|
|
|
|
string value = config.GetOrAddProperty<string>(
|
|
"key",
|
|
() => throw new InvalidOperationException("factory must not run"));
|
|
|
|
Assert.That(value, Is.EqualTo("existing"));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// Concurrent callers all observe the same instance, which is what the
|
|
/// removed PropertiesLock was taken for.
|
|
/// </summary>
|
|
public void GetOrAddPropertyPublishesOneInstanceToConcurrentCallers()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
const int callers = 32;
|
|
using var start = new ManualResetEventSlim(false);
|
|
|
|
object[] results = new object[callers];
|
|
var threads = new Thread[callers];
|
|
for (int i = 0; i < callers; i++)
|
|
{
|
|
int index = i;
|
|
threads[i] = new Thread(() =>
|
|
{
|
|
start.Wait();
|
|
results[index] = config.GetOrAddProperty("key", () => new object());
|
|
});
|
|
threads[i].Start();
|
|
}
|
|
|
|
start.Set();
|
|
foreach (Thread thread in threads)
|
|
{
|
|
thread.Join();
|
|
}
|
|
|
|
Assert.That(results, Is.All.SameAs(results[0]));
|
|
Assert.That(config.Properties["key"], Is.SameAs(results[0]));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// GetOrAddProperty rejects a null key or factory.
|
|
/// </summary>
|
|
public void GetOrAddPropertyThrowsOnNullArguments()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
|
|
Assert.That(
|
|
() => config.GetOrAddProperty(null!, () => "x"),
|
|
Throws.TypeOf<ArgumentNullException>());
|
|
Assert.That(
|
|
() => config.GetOrAddProperty<string>("key", null!),
|
|
Throws.TypeOf<ArgumentNullException>());
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ExtensionObjects list is accessible.
|
|
/// </summary>
|
|
public void ExtensionObjectsIsAccessible()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.That(config.ExtensionObjects, Is.Not.Null);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// CreateMessageContext returns a valid ServiceMessageContext.
|
|
/// </summary>
|
|
public void CreateMessageContextReturnsValidContext()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
ServiceMessageContext context = config.CreateMessageContext();
|
|
Assert.That(context, Is.Not.Null);
|
|
Assert.That(context.NamespaceUris, Is.Not.Null);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// CreateMessageContext with factory returns context with that factory.
|
|
/// </summary>
|
|
public void CreateMessageContextWithFactory()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
IEncodeableFactory factory = EncodeableFactory.Create();
|
|
ServiceMessageContext context = config.CreateMessageContext(factory);
|
|
Assert.That(context, Is.Not.Null);
|
|
Assert.That(context.Factory, Is.SameAs(factory));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// CreateMessageContext applies TransportQuotas when set.
|
|
/// </summary>
|
|
public void CreateMessageContextAppliesTransportQuotas()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
TransportQuotas = new TransportQuotas
|
|
{
|
|
MaxArrayLength = 42,
|
|
MaxStringLength = 99,
|
|
MaxByteStringLength = 101,
|
|
MaxMessageSize = 5000,
|
|
MaxEncodingNestingLevels = 7,
|
|
MaxDecoderRecoveries = 3
|
|
}
|
|
};
|
|
|
|
ServiceMessageContext context = config.CreateMessageContext();
|
|
Assert.That(context.MaxArrayLength, Is.EqualTo(42));
|
|
Assert.That(context.MaxStringLength, Is.EqualTo(99));
|
|
Assert.That(context.MaxByteStringLength, Is.EqualTo(101));
|
|
Assert.That(context.MaxMessageSize, Is.EqualTo(5000));
|
|
Assert.That(context.MaxEncodingNestingLevels, Is.EqualTo(7));
|
|
Assert.That(context.MaxDecoderRecoveries, Is.EqualTo(3));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// CreateMessageContext without TransportQuotas uses defaults.
|
|
/// </summary>
|
|
public void CreateMessageContextWithoutTransportQuotasUsesDefaults()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
ServiceMessageContext context = config.CreateMessageContext();
|
|
Assert.That(context.MaxArrayLength, Is.EqualTo(DefaultEncodingLimits.MaxArrayLength));
|
|
Assert.That(context.MaxStringLength, Is.EqualTo(DefaultEncodingLimits.MaxStringLength));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// GetServerDomainNames returns empty when ServerConfiguration is null.
|
|
/// </summary>
|
|
public void GetServerDomainNamesReturnsEmptyWhenNoServerConfig()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
ArrayOf<string> domains = config.GetServerDomainNames();
|
|
Assert.That(domains.Count, Is.Zero);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// GetServerDomainNames extracts domains from base addresses.
|
|
/// </summary>
|
|
public void GetServerDomainNamesExtractsFromBaseAddresses()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ServerConfiguration = new ServerConfiguration()
|
|
};
|
|
config.ServerConfiguration.BaseAddresses = new List<string>
|
|
{
|
|
"opc.tcp://myserver:4840"
|
|
}.ToArrayOf();
|
|
|
|
ArrayOf<string> domains = config.GetServerDomainNames();
|
|
Assert.That(domains.Count, Is.GreaterThan(0));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// GetServerDomainNames deduplicates domain names.
|
|
/// </summary>
|
|
public void GetServerDomainNamesDeduplicates()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ServerConfiguration = new ServerConfiguration()
|
|
};
|
|
config.ServerConfiguration.BaseAddresses = new List<string>
|
|
{
|
|
"opc.tcp://myserver:4840",
|
|
"https://myserver:4843"
|
|
}.ToArrayOf();
|
|
|
|
ArrayOf<string> domains = config.GetServerDomainNames();
|
|
Assert.That(domains.Count, Is.EqualTo(1));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// GetServerDomainNames includes alternate base addresses.
|
|
/// </summary>
|
|
public void GetServerDomainNamesIncludesAlternateAddresses()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ServerConfiguration = new ServerConfiguration()
|
|
};
|
|
config.ServerConfiguration.BaseAddresses = new List<string>
|
|
{
|
|
"opc.tcp://server1:4840"
|
|
}.ToArrayOf();
|
|
config.ServerConfiguration.AlternateBaseAddresses = new List<string>
|
|
{
|
|
"opc.tcp://server2:4840"
|
|
}.ToArrayOf();
|
|
|
|
ArrayOf<string> domains = config.GetServerDomainNames();
|
|
Assert.That(domains.Count, Is.EqualTo(2));
|
|
}
|
|
|
|
[TestCase("https")]
|
|
[TestCase("opc.https")]
|
|
[TestCase("wss")]
|
|
[TestCase("opc.wss")]
|
|
[TestCase("HTTPS")]
|
|
[TestCase("Opc.Https")]
|
|
[TestCase("WSS")]
|
|
[TestCase("Opc.Wss")]
|
|
public void GetServerDomainNamesPreservesConfiguredLoopbackName(string scheme)
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ServerConfiguration = new ServerConfiguration
|
|
{
|
|
BaseAddresses = [$"{scheme}://localhost:4843", $"{scheme}://LOCALHOST:4844"]
|
|
}
|
|
};
|
|
var expected = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"localhost",
|
|
Utils.GetHostName()
|
|
};
|
|
|
|
ArrayOf<string> domains = config.GetServerDomainNames();
|
|
|
|
Assert.That(domains.ToArray(), Is.EquivalentTo(expected));
|
|
Assert.That(domains.Count, Is.EqualTo(expected.Count));
|
|
}
|
|
|
|
[TestCase("https://127.0.0.1:4843", "127.0.0.1")]
|
|
[TestCase("https://[::1]:4843", "::1")]
|
|
public void GetServerDomainNamesPreservesIpAddressIdentity(string endpoint, string expected)
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ServerConfiguration = new ServerConfiguration
|
|
{
|
|
BaseAddresses = [endpoint]
|
|
}
|
|
};
|
|
|
|
ArrayOf<string> domains = config.GetServerDomainNames();
|
|
|
|
Assert.That(domains.Count, Is.EqualTo(1));
|
|
Assert.That(domains[0], Is.EqualTo(expected));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ValidateAsync throws when ApplicationName is empty.
|
|
/// </summary>
|
|
public void ValidateAsyncThrowsWhenApplicationNameEmpty()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await config.ValidateAsync(ApplicationType.Server).ConfigureAwait(false));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ValidateAsync throws when SecurityConfiguration is null.
|
|
/// </summary>
|
|
public void ValidateAsyncThrowsWhenSecurityConfigurationNull()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ApplicationName = "TestApp"
|
|
};
|
|
// Force SecurityConfiguration to a new instance with no trust lists,
|
|
// then null it out through reflection since the setter prevents null.
|
|
// Instead, test with a valid name but rely on the SecurityConfiguration.Validate
|
|
// to throw since there are no certificate stores configured.
|
|
// The setter prevents null, so SecurityConfiguration is always non-null.
|
|
// The validate call will proceed past the null check.
|
|
Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await config.ValidateAsync(ApplicationType.Server).ConfigureAwait(false));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ValidateAsync throws for Server type when ServerConfiguration is null.
|
|
/// </summary>
|
|
public async Task ValidateAsyncThrowsForServerWithNoServerConfiguration()
|
|
{
|
|
ApplicationConfiguration config = CreateMinimalValidatableConfig();
|
|
config.ApplicationType = ApplicationType.Server;
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await config.ValidateAsync(ApplicationType.Server).ConfigureAwait(false));
|
|
Assert.That(ex, Is.Not.Null);
|
|
await Task.CompletedTask.ConfigureAwait(false);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ValidateAsync throws for Client type when ClientConfiguration is null.
|
|
/// </summary>
|
|
public async Task ValidateAsyncThrowsForClientWithNoClientConfiguration()
|
|
{
|
|
ApplicationConfiguration config = CreateMinimalValidatableConfig();
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await config.ValidateAsync(ApplicationType.Client).ConfigureAwait(false));
|
|
Assert.That(ex, Is.Not.Null);
|
|
await Task.CompletedTask.ConfigureAwait(false);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// ValidateAsync throws for DiscoveryServer type when DiscoveryServerConfiguration is null.
|
|
/// </summary>
|
|
public async Task ValidateAsyncThrowsForDiscoveryServerWithNoConfig()
|
|
{
|
|
ApplicationConfiguration config = CreateMinimalValidatableConfig();
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await config.ValidateAsync(ApplicationType.DiscoveryServer).ConfigureAwait(false));
|
|
Assert.That(ex, Is.Not.Null);
|
|
await Task.CompletedTask.ConfigureAwait(false);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// SourceFilePath is null by default.
|
|
/// </summary>
|
|
public void SourceFilePathDefaultIsNull()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
Assert.That(config.SourceFilePath, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// Extensions property get/set round-trips correctly.
|
|
/// </summary>
|
|
public void ExtensionsGetSet()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
Extensions = new List<XmlElement>().ToArrayOf()
|
|
};
|
|
Assert.That(config.Extensions.IsNull, Is.False);
|
|
Assert.That(config.Extensions.Count, Is.Zero);
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// Properties dictionary supports adding and retrieving values.
|
|
/// </summary>
|
|
public void PropertiesCanStoreAndRetrieveValues()
|
|
{
|
|
var config = new ApplicationConfiguration(m_telemetry);
|
|
config.Properties["key1"] = "value1";
|
|
Assert.That(config.Properties["key1"], Is.EqualTo("value1"));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// Copy constructor copies ServerConfiguration reference.
|
|
/// </summary>
|
|
public void CopyConstructorCopiesServerConfiguration()
|
|
{
|
|
var serverConfig = new ServerConfiguration();
|
|
var original = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ApplicationName = "CopyTest",
|
|
ServerConfiguration = serverConfig
|
|
};
|
|
|
|
var copy = new ApplicationConfiguration(original);
|
|
Assert.That(copy.ServerConfiguration, Is.SameAs(serverConfig));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// Copy constructor copies ClientConfiguration reference.
|
|
/// </summary>
|
|
public void CopyConstructorCopiesClientConfiguration()
|
|
{
|
|
var clientConfig = new ClientConfiguration();
|
|
var original = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ApplicationName = "CopyTest",
|
|
ClientConfiguration = clientConfig
|
|
};
|
|
|
|
var copy = new ApplicationConfiguration(original);
|
|
Assert.That(copy.ClientConfiguration, Is.SameAs(clientConfig));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// Copy constructor copies CertificateManager by reference.
|
|
/// </summary>
|
|
public void CopyConstructorCopiesCertificateManager()
|
|
{
|
|
using var manager = new CertificateManager(m_telemetry);
|
|
var original = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ApplicationName = "CopyTest",
|
|
CertificateManager = manager
|
|
};
|
|
|
|
var copy = new ApplicationConfiguration(original);
|
|
Assert.That(copy.CertificateManager, Is.SameAs(manager));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// Copy constructor copies TransportQuotas reference.
|
|
/// </summary>
|
|
public void CopyConstructorCopiesTransportQuotas()
|
|
{
|
|
var quotas = new TransportQuotas();
|
|
var original = new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ApplicationName = "CopyTest",
|
|
TransportQuotas = quotas
|
|
};
|
|
|
|
var copy = new ApplicationConfiguration(original);
|
|
Assert.That(copy.TransportQuotas, Is.SameAs(quotas));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// TransportQuotas default values match DefaultEncodingLimits.
|
|
/// </summary>
|
|
public void TransportQuotasDefaultValues()
|
|
{
|
|
var quotas = new TransportQuotas();
|
|
Assert.That(quotas.MaxArrayLength, Is.EqualTo(DefaultEncodingLimits.MaxArrayLength));
|
|
Assert.That(quotas.MaxStringLength, Is.EqualTo(DefaultEncodingLimits.MaxStringLength));
|
|
Assert.That(quotas.MaxByteStringLength, Is.EqualTo(DefaultEncodingLimits.MaxByteStringLength));
|
|
Assert.That(quotas.MaxMessageSize, Is.EqualTo(DefaultEncodingLimits.MaxMessageSize));
|
|
Assert.That(quotas.MaxEncodingNestingLevels, Is.EqualTo(DefaultEncodingLimits.MaxEncodingNestingLevels));
|
|
Assert.That(quotas.MaxDecoderRecoveries, Is.EqualTo(DefaultEncodingLimits.MaxDecoderRecoveries));
|
|
}
|
|
|
|
[Test]
|
|
/// <summary>
|
|
/// GetFilePathFromAppConfig returns a non-null path.
|
|
/// </summary>
|
|
public void GetFilePathFromAppConfigReturnsPath()
|
|
{
|
|
string path = ApplicationConfiguration.GetFilePathFromAppConfig(
|
|
"TestSection",
|
|
m_telemetry.CreateLogger<ApplicationConfigurationTests>());
|
|
Assert.That(path, Is.Not.Null);
|
|
Assert.That(path, Does.Contain("TestSection"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// ValidateAsync defaults TransportQuotas when it is null so the transport
|
|
/// layer does not fail with a NullReferenceException on server start.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ValidateAsyncDefaultsTransportQuotasWhenNull()
|
|
{
|
|
string pkiPath = Path.Combine(
|
|
Path.GetTempPath(),
|
|
"OpcUaTestTransportQuotas_" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
ApplicationConfiguration config = CreateValidatableServerConfig(pkiPath);
|
|
config.TransportQuotas = null;
|
|
|
|
await config.ValidateAsync(ApplicationType.Server).ConfigureAwait(false);
|
|
|
|
Assert.That(config.TransportQuotas, Is.Not.Null);
|
|
Assert.That(
|
|
config.TransportQuotas!.MaxMessageSize,
|
|
Is.EqualTo(DefaultEncodingLimits.MaxMessageSize));
|
|
}
|
|
finally
|
|
{
|
|
TryDeleteDirectory(pkiPath);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// ValidateAsync preserves an explicitly configured TransportQuotas instance.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ValidateAsyncKeepsExplicitTransportQuotas()
|
|
{
|
|
string pkiPath = Path.Combine(
|
|
Path.GetTempPath(),
|
|
"OpcUaTestTransportQuotas_" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
ApplicationConfiguration config = CreateValidatableServerConfig(pkiPath);
|
|
var quotas = new TransportQuotas { MaxMessageSize = 1234567 };
|
|
config.TransportQuotas = quotas;
|
|
|
|
await config.ValidateAsync(ApplicationType.Server).ConfigureAwait(false);
|
|
|
|
Assert.That(config.TransportQuotas, Is.SameAs(quotas));
|
|
}
|
|
finally
|
|
{
|
|
TryDeleteDirectory(pkiPath);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// ValidateAsync defaults TransportQuotas when it is null on the client path,
|
|
/// ensuring the behavior is consistent regardless of ApplicationType.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ValidateAsyncDefaultsTransportQuotasWhenNullForClient()
|
|
{
|
|
string pkiPath = Path.Combine(
|
|
Path.GetTempPath(),
|
|
"OpcUaTestTransportQuotasClient_" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
ApplicationConfiguration config = CreateValidatableClientConfig(pkiPath);
|
|
config.TransportQuotas = null;
|
|
|
|
await config.ValidateAsync(ApplicationType.Client).ConfigureAwait(false);
|
|
|
|
Assert.That(config.TransportQuotas, Is.Not.Null);
|
|
Assert.That(
|
|
config.TransportQuotas!.MaxMessageSize,
|
|
Is.EqualTo(DefaultEncodingLimits.MaxMessageSize));
|
|
}
|
|
finally
|
|
{
|
|
TryDeleteDirectory(pkiPath);
|
|
}
|
|
}
|
|
|
|
private ApplicationConfiguration CreateValidatableClientConfig(string pkiPath)
|
|
{
|
|
const string applicationName = "TransportQuotasTestClient";
|
|
return new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ApplicationName = applicationName,
|
|
ApplicationUri = "urn:test:" + applicationName,
|
|
ApplicationType = ApplicationType.Client,
|
|
ClientConfiguration = new ClientConfiguration(),
|
|
SecurityConfiguration = new SecurityConfiguration
|
|
{
|
|
ApplicationCertificate = new CertificateIdentifier
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiPath, "own"),
|
|
SubjectName = "CN=" + applicationName
|
|
},
|
|
TrustedPeerCertificates = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiPath, "trusted")
|
|
},
|
|
TrustedIssuerCertificates = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiPath, "issuers")
|
|
},
|
|
RejectedCertificateStore = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiPath, "rejected")
|
|
},
|
|
AutoAcceptUntrustedCertificates = true
|
|
}
|
|
};
|
|
}
|
|
|
|
private ApplicationConfiguration CreateValidatableServerConfig(string pkiPath)
|
|
{
|
|
const string applicationName = "TransportQuotasTestServer";
|
|
return new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ApplicationName = applicationName,
|
|
ApplicationUri = "urn:test:" + applicationName,
|
|
ApplicationType = ApplicationType.Server,
|
|
ServerConfiguration = new ServerConfiguration(),
|
|
SecurityConfiguration = new SecurityConfiguration
|
|
{
|
|
ApplicationCertificate = new CertificateIdentifier
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiPath, "own"),
|
|
SubjectName = "CN=" + applicationName
|
|
},
|
|
TrustedPeerCertificates = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiPath, "trusted")
|
|
},
|
|
TrustedIssuerCertificates = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiPath, "issuers")
|
|
},
|
|
RejectedCertificateStore = new CertificateTrustList
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiPath, "rejected")
|
|
},
|
|
AutoAcceptUntrustedCertificates = true
|
|
}
|
|
};
|
|
}
|
|
|
|
private static void TryDeleteDirectory(string path)
|
|
{
|
|
try
|
|
{
|
|
if (Directory.Exists(path))
|
|
{
|
|
Directory.Delete(path, true);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// best effort cleanup
|
|
}
|
|
}
|
|
|
|
private ApplicationConfiguration CreateMinimalValidatableConfig()
|
|
{
|
|
return new ApplicationConfiguration(m_telemetry)
|
|
{
|
|
ApplicationName = "TestApp",
|
|
ApplicationUri = "urn:test:app"
|
|
};
|
|
}
|
|
}
|
|
}
|