/* ========================================================================
* 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 NUnit.Framework;
using Opc.Ua.Tests;
namespace Opc.Ua.Server.Tests
{
///
/// The response header of a request that wrote no diagnostic strings is built with an
/// empty string table of the thread; overrides of the response hook must not leak
/// strings into later responses through it.
///
[TestFixture]
[Category("StandardServer")]
[Parallelizable]
public class StandardServerResponseStringTableTests
{
///
/// A request without diagnostic strings gets an empty, not a null, string table.
///
[Test]
public void ResponseWithoutStringsHasEmptyStringTable()
{
using var server = new HookServer();
ResponseHeader header = server.CreateResponse(new RequestHeader { RequestHandle = 7 }, NewContext());
Assert.That(header.RequestHandle, Is.EqualTo(7u));
Assert.That(header.StringTable.IsNull, Is.False);
Assert.That(header.StringTable.Count, Is.Zero);
}
///
/// An override that adds strings to the table and then throws must not hand those
/// strings to the next response created on the thread.
///
[Test]
public void ThrowingOverrideDoesNotLeakStringsIntoTheNextResponse()
{
using var server = new HookServer();
server.Hook = (header, strings) =>
{
strings.Append("leaked");
throw new InvalidOperationException("hook failed");
};
Assert.That(
() => server.CreateResponse(new RequestHeader(), NewContext()),
Throws.InvalidOperationException);
server.Hook = null;
ResponseHeader next = server.CreateResponse(new RequestHeader(), NewContext());
Assert.That(next.StringTable.Count, Is.Zero);
}
///
/// Strings an override adds go into its own response only.
///
[Test]
public void StringsAddedByOverrideStayInItsResponse()
{
using var server = new HookServer();
server.Hook = (header, strings) =>
{
strings.Append("mine");
return server.BaseCreateResponse(header, strings);
};
ResponseHeader first = server.CreateResponse(new RequestHeader(), NewContext());
Assert.That(first.StringTable.Count, Is.EqualTo(1));
Assert.That(first.StringTable[0], Is.EqualTo("mine"));
server.Hook = null;
ResponseHeader next = server.CreateResponse(new RequestHeader(), NewContext());
Assert.That(next.StringTable.Count, Is.Zero);
}
///
/// A response created while the hook runs (re-entrantly) gets a table of its own.
///
[Test]
public void ReentrantResponseGetsItsOwnTable()
{
using var server = new HookServer();
var tables = new List();
server.Hook = (header, strings) =>
{
tables.Add(strings);
if (tables.Count == 1)
{
strings.Append("outer");
ResponseHeader inner = server.CreateResponse(new RequestHeader(), NewContext());
Assert.That(inner.StringTable.Count, Is.Zero);
}
return server.BaseCreateResponse(header, strings);
};
ResponseHeader outer = server.CreateResponse(new RequestHeader(), NewContext());
Assert.That(tables, Has.Count.EqualTo(2));
Assert.That(tables[1], Is.Not.SameAs(tables[0]));
Assert.That(outer.StringTable.Count, Is.EqualTo(1));
}
///
/// A request that wrote diagnostic strings gets them in its response.
///
[Test]
public void ResponseCarriesTheStringsOfTheRequest()
{
using var server = new HookServer();
OperationContext context = NewContext();
context.StringTable.Append("diagnostic");
ResponseHeader header = server.CreateResponse(new RequestHeader(), context);
Assert.That(header.StringTable.Count, Is.EqualTo(1));
Assert.That(header.StringTable[0], Is.EqualTo("diagnostic"));
}
///
/// The default header a service offers is filled and returned instead of a new one,
/// with every field the response header had before.
///
[Test]
public void OfferedHeaderIsFilledWithEveryField()
{
using var server = new HookServer();
var offered = new ResponseHeader();
OperationContext context = NewContext();
context.StringTable.Append("diagnostic");
DateTimeUtc before = DateTimeUtc.Now;
ResponseHeader header = server.CreateResponse(new RequestHeader { RequestHandle = 9 }, context, offered);
Assert.That(header, Is.SameAs(offered));
Assert.That(header.RequestHandle, Is.EqualTo(9u));
Assert.That(header.Timestamp, Is.GreaterThanOrEqualTo(before));
Assert.That(header.Timestamp, Is.LessThanOrEqualTo(DateTimeUtc.Now));
Assert.That(header.ServiceResult, Is.EqualTo(StatusCodes.Good));
Assert.That(header.ServiceDiagnostics, Is.Null);
Assert.That(header.StringTable.Count, Is.EqualTo(1));
Assert.That(header.StringTable[0], Is.EqualTo("diagnostic"));
}
///
/// An override that does not call the base keeps returning its own header, the
/// offered header stays untouched, and a later call does not get it.
///
[Test]
public void OverrideWithoutBaseKeepsItsOwnHeader()
{
using var server = new HookServer();
var own = new ResponseHeader { RequestHandle = 5 };
server.Hook = (header, strings) => own;
var offered = new ResponseHeader();
ResponseHeader result = server.CreateResponse(new RequestHeader { RequestHandle = 9 }, NewContext(), offered);
Assert.That(result, Is.SameAs(own));
Assert.That(offered.RequestHandle, Is.Zero);
Assert.That(offered.Timestamp, Is.EqualTo(DateTimeUtc.MinValue));
server.Hook = null;
ResponseHeader next = server.CreateResponse(new RequestHeader { RequestHandle = 10 }, NewContext());
Assert.That(next, Is.Not.SameAs(offered));
Assert.That(next.RequestHandle, Is.EqualTo(10u));
}
///
/// An override that calls the base gets the offered header and may change it.
///
[Test]
public void OverrideCallingBaseFillsTheOfferedHeader()
{
using var server = new HookServer();
server.Hook = (header, strings) =>
{
ResponseHeader created = server.BaseCreateResponse(header, strings);
created.ServiceResult = StatusCodes.GoodCallAgain;
return created;
};
var offered = new ResponseHeader();
ResponseHeader result = server.CreateResponse(new RequestHeader { RequestHandle = 3 }, NewContext(), offered);
Assert.That(result, Is.SameAs(offered));
Assert.That(result.RequestHandle, Is.EqualTo(3u));
Assert.That(result.ServiceResult, Is.EqualTo(StatusCodes.GoodCallAgain));
}
///
/// A response created while the hook runs (re-entrantly) never shares the header
/// offered for the outer response.
///
[Test]
public void ReentrantResponseDoesNotShareTheOfferedHeader()
{
using var server = new HookServer();
ResponseHeader? inner = null;
server.Hook = (header, strings) =>
{
if (inner == null)
{
server.Hook = null;
inner = server.CreateResponse(new RequestHeader { RequestHandle = 2 }, NewContext());
server.Hook = (h, s) => server.BaseCreateResponse(h, s);
}
return server.BaseCreateResponse(header, strings);
};
var offered = new ResponseHeader();
ResponseHeader outer = server.CreateResponse(new RequestHeader { RequestHandle = 1 }, NewContext(), offered);
Assert.That(inner, Is.Not.Null);
Assert.That(inner, Is.Not.SameAs(outer));
Assert.That(inner!.RequestHandle, Is.EqualTo(2u));
Assert.That(outer.RequestHandle, Is.EqualTo(1u));
}
private static OperationContext NewContext()
{
return new OperationContext(new RequestHeader(), null, RequestType.Read, RequestLifetime.None);
}
///
/// A server whose response hook a test replaces.
///
private sealed class HookServer : StandardServer
{
public HookServer()
: base(NUnitTelemetryContext.Create())
{
}
public Func? Hook { get; set; }
public ResponseHeader BaseCreateResponse(RequestHeader requestHeader, StringTable stringTable)
{
return base.CreateResponse(requestHeader, stringTable);
}
protected override ResponseHeader CreateResponse(RequestHeader requestHeader, StringTable stringTable)
{
Func? hook = Hook;
return hook != null ? hook(requestHeader, stringTable) : base.CreateResponse(requestHeader, stringTable);
}
}
}
}