mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-27 04:59:55 +08:00
Follow-up to #4128, which merged the WoT Connectivity 1.1 work against **draft2** of the companion specifications. Both drafts have moved since, and this branch tracks them through to **draft3**, adds the projection feature they introduced, and closes the gaps a bidirectional re-audit found. The specifications are the private [`OPCF-Members/spec-drafts`](https://github.com/OPCF-Members/spec-drafts) drafts of *OPC UA — WoT Connectivity 1.1* and *OPC UA — WoT Binding 1.1*. They are still drafts, which is why this is opened as a draft PR — see [Status](#status) at the bottom. ## What changed in the specifications Two revisions landed upstream while #4128 was in flight. **draft2** replaced the `eventGroup` / `actionGroup` grouping constructs with a single **projection / view** concept, added fifteen Section 6 vocabulary terms, and made two identifier rules normative. **draft3** ([spec PR #8](https://github.com/OPCF-Members/spec-drafts/pull/8)) un-deprecated the incorporated OPC 10100-1 v1.02 surface, restructured the conformance clause into three profiles, and tightened two security obligations. The draft3 NodeSet is **structurally identical** to draft2 — I diffed all 286 nodes node-by-node. Every change is metadata: 96 `ReleaseStatus="Deprecated"` attributes removed, `PublicationDate` advanced, and `WOTC-ProjectionMaterialization` claimed by three Type Nodes that previously implemented it in name only. ## Projections (WoT Binding Section 12) The substantial addition. A projection document selects affordances from other Thing Descriptions or Thing Models and resolves to a document of its own; materialized, it becomes an OPC UA `View` that **creates no affordance Node** and only `Organizes` what its sources already materialized. | Piece | Where | |---|---| | Document model and validation | `Opc.Ua.Types/Wot/WotProjection.cs`, `WotProjectionModel.cs` | | Four-stage resolution engine | `Opc.Ua.Types/Wot/WotProjectionResolver.cs` | | Materialization plan | `Opc.Ua.WotCon.Server/Materialization/WotViewProjectionPlan.cs`, `WotProjectionViewBuilder.cs` | | Live address-space host | `WotProjectionViewNodeManager.cs`, `LifecycleWotViewProjectionHost.cs`, `IWotViewProjectionHost.cs` | | Node lookup seam | `IWotMaterializedNodeIndex.cs` | Groups reached by `ua:Organizes` become organizational Objects rather than nested Views, only the outermost materialization is a `View`, and a member whose source is served by another server is omitted and reported rather than recreated — the federation case the clause calls out. ### How this is validated The specification publishes twenty worked examples, two of which are a **golden pair**: a projection document and the resolved view it is defined to produce. `WotSpecExampleTests` embeds all twenty and runs the pair through the resolver, asserting against *the specification's own expected output* rather than against my reading of the prose. That single document exercises all three selection forms, the bulk naming rule, the security closure naming and the provenance term. This is the strongest evidence in the PR, and it is worth more than the unit tests around it: it is the same check the upstream `validate_local.py` performs. ## Other spec work - **Section 6 vocabulary** — fifteen missing model and platform terms with their validation rules (`WotNodeSetConverter.ModelVocabulary.cs`). - **Portable identifiers** — the two rules release 1.1 made normative, with `WotNodeSetConverterOptions.AllowNonPortableIdentifiers` as a documented downgrade-to-warning switch for documents authored against v1.00. Defaults to `false`. - **xRegistry §6.9 identifier construction** (`XRegistryIdentifier.cs`) — reverse-authority symbolic identifiers; all seven published worked examples are test cases. ## Gaps found by re-auditing against draft3 I audited in both directions — every normative requirement to code, and every `uav:` term in code back to a spec that defines it. Three findings, all fixed: 1. **`Wot-Con 1.02` accepted anything well-formed.** draft3 makes format validation part of the unit, because *WoT-Con Minimal* is one unit wide and materialization projects a *valid* document. The 1.02 upload path only deserialized into the `ThingDescription` POCO, which happily accepts JSON that is not a WoT document at all — every member of that type is optional, so `{}` deserialized and was materialized. It now parses via `WotDocument.Parse` and requires the document to identify itself by a non-empty `name` or `title`, returning `Bad_DecodingError` and materializing nothing otherwise. The rule lives in `ThingDescriptionFormatValidator` and is shared with `CreateAssetForEndpoint`, whose auto-generated Thing Descriptions §11 also treats as untrusted input. 2. **Two `WoTFile` operations were ungated.** draft3 §11 names `Write` and `CloseAndUpdate` alongside the five management methods; both reached the materializer without an access check. `EnforceManagementAccess` is now wired into `WotAssetFileManager`. 3. **Two invented `uav:` terms.** `uav:NodeSet2Preservation` was declared and never used — removed. `uav:eventFields` is read by `OpcUaBindingPlanner` and defined in *neither* spec, while a comment claimed it came from OPC 10101. It still works for compatibility but is now documented as a non-standard extension at both sites rather than misattributed. The SSRF prohibition is satisfied by construction: there is no HTTP client anywhere in these assemblies, and all resolution goes through the injected `IWotThingResolver`. ## Two ambiguities fixed in the specifications themselves The audit turned up two places where a conforming implementer had to invent an answer. Rather than pick silently, I took both upstream — they are pushed to [spec-drafts PR #8](https://github.com/OPCF-Members/spec-drafts/pull/8) (all checks green) and are now normative in *Binding* §12.6 and *Connectivity* §7.13: - **`ViewVersion`** had one sentence saying only *that* it changes. It now changes iff the resolved membership changes, is updated at commit time alongside the `NodeVersion` stamping, and is a deterministic function of the membership alone in a canonical order — so two servers that resolved the same membership agree, which a per-server counter would not across a redundant pair. Explicitly **not monotonic**: compare for inequality only. - **"Type-level view"** was used once in §12.2 and defined nowhere, while every other statement of materialization was universal. §12.6 now *defines* it rather than contradicting §12.2: the NodeClass is `View` in both cases. Both were already what this code did, so neither changed behaviour — but the order-insensitivity the clause added was **not** covered by any test, and `ViewVersionIsUnchangedWhenOnlyTheOrderOfTheMembershipChanges` now holds it. It is mutation-verified: removing the ordinal sort turns it red. `docs/WotBindings.md` no longer lists these as points "the specification leaves open". ## Validation | Suite | Result | |---|---| | `Opc.Ua.Types.Tests` | 8525 ✅ (8518 on net48) | | `Opc.Ua.WotCon.Tests` | 1057 ✅ (net10.0 and net48) | | `Opc.Ua.WotCon.Bindings.Tests` | 559 ✅ | | `Opc.Ua.XRegistry.Tests` | 196 ✅ | | `Opc.Ua.Server.Tests` | 4027 ✅ | | `Opc.Ua.SourceGeneration.Core.Tests` | 3771 ✅ | | `Opc.Ua.WotCon.Samples.Tests` | 9 ✅ | The full `UA.slnx` builds with **0 errors and 0 warnings attributable to this PR** on all 6 TFMs (`TreatWarningsAsErrors` is on). `Opc.Ua.Server.Tests` and the source-generation suites are listed because this PR now also touches `MasterNodeManager` and the WoT-Con NodeSet. ## Compatibility No public API is removed or broken. Everything added is new surface using `ArrayOf<T>` / `ByteString` per the repo conventions, wired for DI with a direct-construction fallback, and the one behavioural tightening (`Wot-Con 1.02` format validation) rejects input that was never valid to begin with. One pre-existing issue is **not** addressed here because it came in with #4128 and is out of scope: `WotAssetFileManager.OnCloseAndUpdate` uses `.GetAwaiter().GetResult()`, which the repo rules ban. Worth a follow-up. ## Landed since this PR was opened - **DI upgraded to 1.05.0.** `ConnectsTo` was declared a subtype of `HierarchicalReferences`, contradicting OPC 10000-100 §5.5 Table 48. That is an upstream error in every official DI NodeSet up to 1.04, corrected by the OPC Foundation in 1.05.0, so the fixture is replaced with the verbatim 1.05.0 file rather than hand-patched. A guard test pins the corrected form. This also closed a second gap: six DI types the WoT model was missing are 1.05 additions. - **The sample Pump has the type definition OPC 40223 declares for it.** `Pump1.Identification` carried DI's `FunctionalGroupType` instead of `PumpIdentificationType`. Every test, the sample documents and the README agreed with each other about it, which is why it survived. - **`WotPumpAddressSpaceComparisonTests`** compares this server against `PumpDeviceIntegrationServer`, which builds the same OPC 40223 Pump from generated NodeSets and hand-written wiring. An independent oracle rather than a restatement of the same assumptions - it is what found the defect above. - **Error containment in `MaterializeProjectionViewsAsync`.** An exception mid-loop discarded the applied View handles before they were published, leaving View Nodes in the address space that nothing could remove. - **`HasWoTProjection`** is now created; it was documented and plumbed but never wired. - **The default in-memory View host** no longer deletes the View it has just applied. - **An undeclared NodeSet alias now says so.** It previously surfaced as `IdentifierMissing`, naming neither the value nor the fact that an alias was expected. - **`ViewVersion` follows the algorithm the specification now states.** My earlier spec text was rejected on review, correctly: it named neither the function nor the canonical order it demanded, ignored that OPC 10000-3 §5.4 requires a value greater than zero, and its "when, and only when" is unsatisfiable for a 32-bit value. §12.6 now specifies the algorithm outright and this implementation follows it exactly, pinned by an independently computed test vector. Implementing it surfaced a further defect in the clause - joining members on U+000A is not injective, because a NodeId string identifier may itself contain U+000A - which is fixed in the specification by length-prefixing each member. - **An auto-generated Thing Description is treated as untrusted input.** §11 requires it; `CreateAssetForEndpoint` validated the endpoint but handed whatever the discovery provider returned straight to the materializer. The `Wot-Con 1.02` format rule now gates both paths from one place. - **Document bytes moved out of the registry snapshot** into a content-addressed store, so a commit no longer rewrites the whole corpus to edit one document. Writes stage and are promoted during the commit, which preserves both durability-first ordering and the fail-closed rule that a blob directory without a manifest means operator recovery. Restoring streaming digest verification along the way closed a real gap: content integrity had degenerated to a length comparison. - **External references now reach a NodeManager registered later.** A reference to a Node that does not exist yet is dropped rather than queued, and the dynamic-registration path had no second phase to apply them in, so registering a NodeSet before the one it points into lost the forward edge permanently. Review of that change then found a state leak in its rollback path, fixed with a red-to-green regression test. - **`WOTC-Legacy` is renamed `Wot-Con 1.02`** in the specification and here. Editorial: the NodeSet diff is `<Category>` values only. ## Status Open for review. Two things may still move: 1. **The specifications are unpublished drafts.** Anything here can change if the working group decides differently. The `ViewVersion` definition has since been settled - §12.6 specifies the algorithm and this follows it - but the same could happen to any other clause tracked here. 2. **`uav:eventFields`** is kept as a documented non-standard extension. That is a judgement call the WG may want to reverse, either by adopting the term or removing it. Two known items are recorded rather than fixed: - `Pump1` has no hierarchical parent, so it is reachable by NodeId but not by browsing down from `Objects`. The cross-NodeSet ordering defect this was originally attributed to is fixed above, and cross-manager references are now symmetric in both registration orders. The Pump itself is still blocked on a different cause found while verifying that fix: with the reference added, `DeviceSet` exists but the Pump has **zero** inverse references, so the edge is dropped at *import* - before any external-reference machinery runs. That is a separate defect in the materialization path, not the one that was fixed, and it is left recorded rather than guessed at. - `WotAssetFileManager.OnCloseAndUpdate` uses `.GetAwaiter().GetResult()`, which the repo rules ban. Pre-existing from #4128 and out of scope here. Review of the projection resolver and the materialization host is the most valuable thing to look at; the vocabulary and identifier commits are mechanical by comparison. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
571 lines
22 KiB
C#
571 lines
22 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.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.WotCon.Server.Assets;
|
|
using Opc.Ua.WotCon.Server.ThingDescriptions;
|
|
|
|
namespace Opc.Ua.WotCon.Tests
|
|
{
|
|
/// <summary>
|
|
/// Direct tests for <see cref="WotAssetFileManager"/> that exercise the
|
|
/// Spec §6.3.10 file primitives without spinning up a full server. The
|
|
/// generated <c>WoTAssetFileState</c> is created in isolation via the
|
|
/// source-generated factory; method handlers are invoked directly.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("WotCon")]
|
|
[Parallelizable(ParallelScope.All)]
|
|
public sealed class WotAssetFileManagerTests
|
|
{
|
|
private const byte ModeRead = 1;
|
|
private const byte ModeWriteErase = 6;
|
|
|
|
// ----------------------------------------------------------------
|
|
// Open: mode restriction (§6.3.10) — Read (1) and Write|EraseExisting (6) only.
|
|
// ----------------------------------------------------------------
|
|
|
|
[TestCase((byte)0)]
|
|
[TestCase((byte)2)] // Write only
|
|
[TestCase((byte)3)] // Read+Write
|
|
[TestCase((byte)4)] // EraseExisting only
|
|
[TestCase((byte)5)] // Read+EraseExisting
|
|
[TestCase((byte)7)] // Read+Write+EraseExisting
|
|
[TestCase((byte)8)] // Append only
|
|
[TestCase((byte)10)] // Write+Append
|
|
[TestCase((byte)16)] // unknown high bit
|
|
public void OpenWithUnsupportedModeReturnsBadNotSupported(byte mode)
|
|
{
|
|
using var harness = new Harness();
|
|
uint handle = 0;
|
|
ServiceResult result = harness.Open(mode, ref handle);
|
|
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNotSupported));
|
|
Assert.That(handle, Is.Zero, "No handle is allocated for a rejected mode.");
|
|
}
|
|
|
|
[Test]
|
|
public void OpenWithReadModeSucceeds()
|
|
{
|
|
using var harness = new Harness();
|
|
uint handle = 0;
|
|
ServiceResult result = harness.Open(ModeRead, ref handle);
|
|
|
|
Assert.That(ServiceResult.IsGood(result), Is.True);
|
|
Assert.That(handle, Is.GreaterThan(0u));
|
|
Assert.That(harness.File.OpenCount!.Value, Is.EqualTo((ushort)1));
|
|
}
|
|
|
|
[Test]
|
|
public void OpenWithWriteEraseModeSucceeds()
|
|
{
|
|
using var harness = new Harness();
|
|
uint handle = 0;
|
|
ServiceResult result = harness.Open(ModeWriteErase, ref handle);
|
|
|
|
Assert.That(ServiceResult.IsGood(result), Is.True);
|
|
Assert.That(handle, Is.GreaterThan(0u));
|
|
}
|
|
|
|
[Test]
|
|
public void OpenSecondWriterWhileFirstStillOpenReturnsBadInvalidState()
|
|
{
|
|
using var harness = new Harness();
|
|
uint first = 0;
|
|
harness.Open(ModeWriteErase, ref first);
|
|
uint second = 0;
|
|
ServiceResult result = harness.Open(ModeWriteErase, ref second);
|
|
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState));
|
|
Assert.That(second, Is.Zero);
|
|
}
|
|
|
|
[Test]
|
|
public void OpenBeyondMaxHandlesReturnsBadTooManyOperations()
|
|
{
|
|
using var harness = new Harness(maxOpenHandles: 2);
|
|
uint h1 = 0;
|
|
uint h2 = 0;
|
|
harness.Open(ModeRead, ref h1);
|
|
harness.Open(ModeRead, ref h2);
|
|
uint h3 = 0;
|
|
ServiceResult result = harness.Open(ModeRead, ref h3);
|
|
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadTooManyOperations));
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Read / Write / position primitives.
|
|
// ----------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void WriteThenReadRoundTripsBytes()
|
|
{
|
|
using var harness = new Harness();
|
|
byte[] payload = Encoding.UTF8.GetBytes("hello world");
|
|
|
|
harness.Upload(payload);
|
|
byte[] downloaded = harness.Download();
|
|
|
|
Assert.That(downloaded, Is.EqualTo(payload));
|
|
}
|
|
|
|
[Test]
|
|
public void ReadOnEmptyFileReturnsEmpty()
|
|
{
|
|
using var harness = new Harness();
|
|
uint handle = 0;
|
|
harness.Open(ModeRead, ref handle);
|
|
ByteString data = default;
|
|
ServiceResult result = harness.Read(handle, 256, ref data);
|
|
harness.Close(handle);
|
|
|
|
Assert.That(ServiceResult.IsGood(result), Is.True);
|
|
Assert.That(data.IsNull || data.Span.Length == 0, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void GetPositionAndSetPositionWorkOnReadHandle()
|
|
{
|
|
using var harness = new Harness();
|
|
harness.Upload([1, 2, 3, 4, 5]);
|
|
uint handle = 0;
|
|
harness.Open(ModeRead, ref handle);
|
|
try
|
|
{
|
|
ulong pos = 999;
|
|
ServiceResult getResult = harness.GetPosition(handle, ref pos);
|
|
Assert.That(ServiceResult.IsGood(getResult), Is.True);
|
|
Assert.That(pos, Is.Zero);
|
|
|
|
Assert.That(ServiceResult.IsGood(harness.SetPosition(handle, 3)), Is.True);
|
|
|
|
pos = 0;
|
|
harness.GetPosition(handle, ref pos);
|
|
Assert.That(pos, Is.EqualTo(3ul));
|
|
}
|
|
finally
|
|
{
|
|
harness.Close(handle);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void SetPositionBeyondLengthReturnsBadInvalidArgument()
|
|
{
|
|
using var harness = new Harness();
|
|
harness.Upload([1, 2, 3]);
|
|
uint handle = 0;
|
|
harness.Open(ModeRead, ref handle);
|
|
try
|
|
{
|
|
ServiceResult result = harness.SetPosition(handle, 100);
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
}
|
|
finally
|
|
{
|
|
harness.Close(handle);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void ReadOnWriteHandleReturnsBadInvalidState()
|
|
{
|
|
using var harness = new Harness();
|
|
uint handle = 0;
|
|
harness.Open(ModeWriteErase, ref handle);
|
|
try
|
|
{
|
|
ByteString data = default;
|
|
ServiceResult result = harness.Read(handle, 16, ref data);
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadInvalidState));
|
|
}
|
|
finally
|
|
{
|
|
harness.Close(handle);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void WriteOnReadHandleReturnsBadInvalidState()
|
|
{
|
|
using var harness = new Harness();
|
|
uint handle = 0;
|
|
harness.Open(ModeRead, ref handle);
|
|
try
|
|
{
|
|
ServiceResult result = harness.Write(handle, ByteString.From(new byte[] { 1, 2 }));
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadInvalidState));
|
|
}
|
|
finally
|
|
{
|
|
harness.Close(handle);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void WriteBeyondMaxSizeReturnsBadOutOfMemory()
|
|
{
|
|
using var harness = new Harness(maxThingDescriptionSize: 4);
|
|
uint handle = 0;
|
|
harness.Open(ModeWriteErase, ref handle);
|
|
try
|
|
{
|
|
ServiceResult result = harness.Write(
|
|
handle, ByteString.From(new byte[] { 1, 2, 3, 4, 5 }));
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadOutOfMemory));
|
|
}
|
|
finally
|
|
{
|
|
harness.Close(handle);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void OperationsOnUnknownHandleReturnBadInvalidArgument()
|
|
{
|
|
using var harness = new Harness();
|
|
ByteString data = default;
|
|
ServiceResult readResult = harness.Read(fileHandle: 9999, length: 1, data: ref data);
|
|
ServiceResult closeResult = harness.Close(9999);
|
|
ServiceResult writeResult = harness.Write(9999, ByteString.From(new byte[] { 1 }));
|
|
|
|
Assert.That(readResult.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
Assert.That(closeResult.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
Assert.That(writeResult.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Close vs CloseAndUpdate semantics.
|
|
// ----------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void CloseAfterWriteDiscardsPendingContent()
|
|
{
|
|
using var harness = new Harness();
|
|
uint handle = 0;
|
|
harness.Open(ModeWriteErase, ref handle);
|
|
harness.Write(handle, ByteString.From(Encoding.UTF8.GetBytes("draft")));
|
|
harness.Close(handle);
|
|
|
|
// Persistent content remains unchanged (was empty).
|
|
Assert.That(harness.File.Size!.Value, Is.Zero);
|
|
Assert.That(harness.MaterialiseCallCount, Is.Zero);
|
|
Assert.That(harness.LastMaterialisedTd, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
public async Task CloseAndUpdateWithValidTdInvokesMaterialiseCallbackAndPersistsContent()
|
|
{
|
|
using var harness = new Harness();
|
|
byte[] tdBytes = Encoding.UTF8.GetBytes(/*lang=json,strict*/ """
|
|
{"name":"asset-001","base":"sim://example/asset/1"}
|
|
""");
|
|
|
|
uint handle = 0;
|
|
harness.Open(ModeWriteErase, ref handle);
|
|
harness.Write(handle, ByteString.From(tdBytes));
|
|
ServiceResult result = harness.CloseAndUpdate(handle);
|
|
|
|
Assert.That(ServiceResult.IsGood(result), Is.True);
|
|
Assert.That(harness.MaterialiseCallCount, Is.EqualTo(1));
|
|
Assert.That(harness.LastMaterialisedTd!.Name, Is.EqualTo("asset-001"));
|
|
Assert.That(harness.LastMaterialisedTd.Base,
|
|
Is.EqualTo("sim://example/asset/1"));
|
|
Assert.That(harness.File.Size!.Value, Is.EqualTo((ulong)tdBytes.Length));
|
|
|
|
// Subsequent Read returns the persisted TD bytes.
|
|
byte[] downloaded = harness.Download();
|
|
Assert.That(downloaded, Is.EqualTo(tdBytes));
|
|
await Task.CompletedTask.ConfigureAwait(false);
|
|
}
|
|
|
|
[Test]
|
|
public void CloseAndUpdateWithMalformedJsonReturnsBadDecodingError()
|
|
{
|
|
using var harness = new Harness();
|
|
uint handle = 0;
|
|
harness.Open(ModeWriteErase, ref handle);
|
|
harness.Write(handle, ByteString.From(Encoding.UTF8.GetBytes("{not json")));
|
|
ServiceResult result = harness.CloseAndUpdate(handle);
|
|
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadDecodingError));
|
|
Assert.That(harness.MaterialiseCallCount, Is.Zero);
|
|
// Bad TD must not become the new persisted content.
|
|
Assert.That(harness.File.Size!.Value, Is.Zero);
|
|
}
|
|
|
|
[Test]
|
|
[TestCase("[1, 2, 3]", TestName = "CloseAndUpdateRejectsJsonThatIsNotAnObject")]
|
|
[TestCase("{}", TestName = "CloseAndUpdateRejectsAnObjectWithoutATitle")]
|
|
[TestCase(
|
|
"{\"description\":\"no title here\"}",
|
|
TestName = "CloseAndUpdateRejectsAnObjectWithOnlyOptionalMembers")]
|
|
public void CloseAndUpdateWithWellFormedJsonThatIsNotAThingDescriptionMaterializesNothing(
|
|
string payload)
|
|
{
|
|
// Wot-Con 1.02 requires an uploaded document to be format validated
|
|
// before any node is materialized, and one that fails to materialize
|
|
// nothing and return Bad_DecodingError. Deserializing into the Thing
|
|
// Description shape alone would accept the object cases: every member
|
|
// of that POCO is optional, so "{}" round-trips happily. W3C WoT TD
|
|
// 1.1 section 5.3.1 makes title mandatory, which is what separates a
|
|
// Thing Description from an arbitrary JSON object.
|
|
using var harness = new Harness();
|
|
uint handle = 0;
|
|
harness.Open(ModeWriteErase, ref handle);
|
|
harness.Write(handle, ByteString.From(Encoding.UTF8.GetBytes(payload)));
|
|
ServiceResult result = harness.CloseAndUpdate(handle);
|
|
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo((StatusCode)StatusCodes.BadDecodingError));
|
|
Assert.That(harness.MaterialiseCallCount, Is.Zero);
|
|
Assert.That(harness.File.Size!.Value, Is.Zero);
|
|
}
|
|
|
|
[Test]
|
|
public void CloseAndUpdateOnReadHandleReturnsBadInvalidState()
|
|
{
|
|
using var harness = new Harness();
|
|
uint handle = 0;
|
|
harness.Open(ModeRead, ref handle);
|
|
ServiceResult result = harness.CloseAndUpdate(handle);
|
|
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadInvalidState));
|
|
}
|
|
|
|
[Test]
|
|
public void CloseAndUpdatePropagatesCallbackFailure()
|
|
{
|
|
using var harness = new Harness(
|
|
materialise: (_, _) => new ValueTask<ServiceResult>(
|
|
(ServiceResult)StatusCodes.BadConfigurationError));
|
|
uint handle = 0;
|
|
harness.Open(ModeWriteErase, ref handle);
|
|
harness.Write(handle, ByteString.From(Encoding.UTF8.GetBytes(/*lang=json,strict*/ """{"name":"x"}""")));
|
|
ServiceResult result = harness.CloseAndUpdate(handle);
|
|
|
|
Assert.That(result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadConfigurationError));
|
|
// When the callback fails, the new bytes must not become persistent content.
|
|
Assert.That(harness.File.Size!.Value, Is.Zero);
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Open-count bookkeeping.
|
|
// ----------------------------------------------------------------
|
|
|
|
[Test]
|
|
public void OpenCountIncreasesAndDecreasesWithHandles()
|
|
{
|
|
using var harness = new Harness();
|
|
uint h1 = 0;
|
|
uint h2 = 0;
|
|
harness.Open(ModeRead, ref h1);
|
|
Assert.That(harness.File.OpenCount!.Value, Is.EqualTo((ushort)1));
|
|
harness.Open(ModeRead, ref h2);
|
|
Assert.That(harness.File.OpenCount.Value, Is.EqualTo((ushort)2));
|
|
harness.Close(h1);
|
|
Assert.That(harness.File.OpenCount.Value, Is.EqualTo((ushort)1));
|
|
harness.Close(h2);
|
|
Assert.That(harness.File.OpenCount.Value, Is.Zero);
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Test harness — owns the WoTAssetFileState and the file manager.
|
|
// ----------------------------------------------------------------
|
|
|
|
private sealed class Harness : IDisposable
|
|
{
|
|
private readonly NodeId _objectId;
|
|
private readonly List<ThingDescription> _materialisedTds = [];
|
|
private readonly Func<ThingDescription, CancellationToken, ValueTask<ServiceResult>> _materialiseDelegate;
|
|
|
|
public Harness(
|
|
int maxOpenHandles = 4,
|
|
int maxThingDescriptionSize = 1024 * 1024,
|
|
Func<ThingDescription, CancellationToken, ValueTask<ServiceResult>>? materialise = null)
|
|
{
|
|
Context = new SystemContext(null!)
|
|
{
|
|
NamespaceUris = new NamespaceTable(),
|
|
EncodeableFactory = EncodeableFactory.Create()
|
|
};
|
|
Context.NamespaceUris.GetIndexOrAppend(Namespaces.WotCon);
|
|
Context.NamespaceUris.GetIndexOrAppend("urn:test");
|
|
|
|
_materialiseDelegate = materialise ?? DefaultMaterialise;
|
|
|
|
File = Context.CreateInstanceOfWoTAssetFileType(
|
|
parent: null!,
|
|
browseName: new QualifiedName("WoTFile", 1));
|
|
|
|
// The manager is internal so InternalsVisibleTo gives us access.
|
|
Manager = new WotAssetFileManager(
|
|
File,
|
|
maxOpenHandles,
|
|
maxThingDescriptionSize,
|
|
_materialiseDelegate,
|
|
NullLogger.Instance);
|
|
|
|
_objectId = File.NodeId;
|
|
}
|
|
|
|
public SystemContext Context { get; }
|
|
|
|
public WoTAssetFileState File { get; }
|
|
|
|
public WotAssetFileManager Manager { get; }
|
|
|
|
public int MaterialiseCallCount { get; private set; }
|
|
|
|
public ThingDescription? LastMaterialisedTd
|
|
=> _materialisedTds.Count == 0 ? null : _materialisedTds[^1];
|
|
|
|
public ServiceResult Open(byte mode, ref uint fileHandle)
|
|
{
|
|
return File.Open!.OnCall!.Invoke(Context, File.Open, _objectId, mode, ref fileHandle);
|
|
}
|
|
|
|
public ServiceResult Close(uint fileHandle)
|
|
{
|
|
return File.Close!.OnCall!.Invoke(Context, File.Close, _objectId, fileHandle);
|
|
}
|
|
|
|
public ServiceResult Read(uint fileHandle, int length, ref ByteString data)
|
|
{
|
|
return File.Read!.OnCall!.Invoke(Context, File.Read, _objectId, fileHandle, length, ref data);
|
|
}
|
|
|
|
public ServiceResult Write(uint fileHandle, ByteString data)
|
|
{
|
|
return File.Write!.OnCall!.Invoke(Context, File.Write, _objectId, fileHandle, data);
|
|
}
|
|
|
|
public ServiceResult GetPosition(uint fileHandle, ref ulong position)
|
|
{
|
|
return File.GetPosition!.OnCall!.Invoke(
|
|
Context, File.GetPosition, _objectId, fileHandle, ref position);
|
|
}
|
|
|
|
public ServiceResult SetPosition(uint fileHandle, ulong position)
|
|
{
|
|
return File.SetPosition!.OnCall!.Invoke(
|
|
Context, File.SetPosition, _objectId, fileHandle, position);
|
|
}
|
|
|
|
public ServiceResult CloseAndUpdate(uint fileHandle)
|
|
{
|
|
return File.CloseAndUpdate!.OnCall!.Invoke(
|
|
Context, File.CloseAndUpdate, _objectId, fileHandle);
|
|
}
|
|
|
|
public void Upload(byte[] content)
|
|
{
|
|
uint handle = 0;
|
|
Open(ModeWriteErase, ref handle);
|
|
try
|
|
{
|
|
if (content.Length > 0)
|
|
{
|
|
Assert.That(
|
|
ServiceResult.IsGood(Write(handle, ByteString.From(content))),
|
|
Is.True,
|
|
"Write inside Upload helper failed.");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Close(handle);
|
|
}
|
|
// Close discards pending writes, so seed persistent content explicitly.
|
|
Manager.UpdatePersistedContent(content);
|
|
}
|
|
|
|
public byte[] Download()
|
|
{
|
|
uint handle = 0;
|
|
Open(ModeRead, ref handle);
|
|
try
|
|
{
|
|
var buffer = new List<byte>();
|
|
while (true)
|
|
{
|
|
ByteString chunk = default;
|
|
Read(handle, 1024, ref chunk);
|
|
if (chunk.IsNull || chunk.Span.Length == 0)
|
|
{
|
|
break;
|
|
}
|
|
buffer.AddRange(chunk.Span.ToArray());
|
|
if (chunk.Span.Length < 1024)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
return [.. buffer];
|
|
}
|
|
finally
|
|
{
|
|
Close(handle);
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Manager.Dispose();
|
|
}
|
|
|
|
private ValueTask<ServiceResult> DefaultMaterialise(
|
|
ThingDescription td,
|
|
CancellationToken ct)
|
|
{
|
|
MaterialiseCallCount++;
|
|
_materialisedTds.Add(td);
|
|
return new ValueTask<ServiceResult>(ServiceResult.Good);
|
|
}
|
|
}
|
|
}
|
|
}
|