mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-30 17:30:40 +00:00
Permissions, RateControl and Auditing
This commit is contained in:
@@ -15,14 +15,12 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Diagnostics.Tracing.TraceEvent" Version="3.1.28" />
|
||||
<PackageReference Include="MQTTnet" Version="5.0.1.1416" />
|
||||
<PackageReference Include="protobuf-net.Core" Version="3.2.56" />
|
||||
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="10.0.0" />
|
||||
<PackageReference Include="System.Diagnostics.Tracing" Version="4.3.0" />
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||
<!-- ASP.NET Core 10 permits Microsoft.OpenApi 2.0.0; enforce a patched 2.x floor. -->
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="2.10.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ApacheThrift" Version="0.22.0" />
|
||||
<PackageReference Include="Thrift" Version="0.9.1.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
<PackageReference Include="AvroConvert" Version="3.4.16" />
|
||||
<PackageReference Include="FlatSharp" Version="6.3.5" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.34.0" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.8" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0" PrivateAssets="all" />
|
||||
<PackageReference Include="MongoDB.Bson" Version="3.7.1" />
|
||||
<PackageReference Include="PeterO.Cbor" Version="4.5.5" />
|
||||
<PackageReference Include="protobuf-net" Version="3.2.56" />
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
<PackageReference Include="AvroConvert" Version="3.4.16" />
|
||||
<PackageReference Include="FlatSharp" Version="6.3.5" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.34.0" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.8" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0" PrivateAssets="all" />
|
||||
<PackageReference Include="MongoDB.Bson" Version="3.7.1" />
|
||||
<PackageReference Include="PeterO.Cbor" Version="4.5.5" />
|
||||
<PackageReference Include="protobuf-net" Version="3.2.56" />
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Esiur.Core;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public class AsyncBagTests
|
||||
{
|
||||
[Fact]
|
||||
public void Seal_CollectsConcurrentReplyCompletionsExactlyOnce()
|
||||
{
|
||||
const int count = 2_000;
|
||||
var replies = Enumerable.Range(0, count)
|
||||
.Select(_ => new AsyncReply<int>())
|
||||
.ToArray();
|
||||
var bag = new AsyncBag<int>();
|
||||
foreach (var reply in replies)
|
||||
bag.Add(reply);
|
||||
|
||||
bag.Seal();
|
||||
Parallel.For(0, replies.Length, index => replies[index].Trigger(index));
|
||||
|
||||
Assert.Equal(Enumerable.Range(0, count), bag.Wait(5_000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddBag_UsesAStableSnapshot()
|
||||
{
|
||||
var source = new AsyncBag<int>();
|
||||
source.Add(1);
|
||||
source.Add(new AsyncReply<int>(2));
|
||||
|
||||
var destination = new AsyncBag<int>();
|
||||
destination.AddBag(source);
|
||||
destination.Seal();
|
||||
|
||||
Assert.Equal(new[] { 1, 2 }, destination.Wait(1_000));
|
||||
}
|
||||
}
|
||||
@@ -94,4 +94,27 @@ public class AsyncQueueTests
|
||||
|
||||
Assert.Equal(Enumerable.Range(1, itemCount), sequences.OrderBy(x => x));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeReadyBatch_IsDeliveredInOrder()
|
||||
{
|
||||
const int itemCount = 5_000;
|
||||
var queue = new AsyncQueue<int>();
|
||||
var replies = Enumerable.Range(0, itemCount)
|
||||
.Select(_ => new AsyncReply<int>())
|
||||
.ToArray();
|
||||
var delivered = new List<int>(itemCount);
|
||||
queue.Then(delivered.Add);
|
||||
|
||||
foreach (var reply in replies)
|
||||
queue.Add(reply);
|
||||
|
||||
// Keep the head pending while every later item becomes ready, then release
|
||||
// the whole batch at once. This exercises the bulk-removal path.
|
||||
for (var i = 1; i < replies.Length; i++)
|
||||
replies[i].Trigger(i);
|
||||
replies[0].Trigger(0);
|
||||
|
||||
Assert.Equal(Enumerable.Range(0, itemCount), delivered);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
using Esiur.Core;
|
||||
using Esiur.Data.Types;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public class AsyncReplyConcurrencyTests
|
||||
{
|
||||
[Fact]
|
||||
public void CompletedRepliesKeepCallbackAndWaiterStorageLazy()
|
||||
{
|
||||
var reply = new AsyncReply<int>(42);
|
||||
var awaiter = reply.GetAwaiter();
|
||||
|
||||
Assert.True(reply.Ready);
|
||||
Assert.True(awaiter.IsCompleted);
|
||||
Assert.Equal(42, awaiter.GetResult());
|
||||
Assert.Null(GetBaseField(reply, "callbacks"));
|
||||
Assert.Null(GetBaseField(reply, "errorCallbacks"));
|
||||
Assert.Null(GetBaseField(reply, "completionEvent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompletedReplyAllocationStaysWithinObjectAndLockBudget()
|
||||
{
|
||||
const int count = 4096;
|
||||
const int maximumBytesPerReply = 224;
|
||||
var replies = new AsyncReply<int>[count];
|
||||
|
||||
// Warm up constructors and property access before measuring this thread.
|
||||
for (var i = 0; i < 32; i++)
|
||||
_ = new AsyncReply<int>(i).Ready;
|
||||
|
||||
var before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (var i = 0; i < replies.Length; i++)
|
||||
replies[i] = new AsyncReply<int>(i);
|
||||
|
||||
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
GC.KeepAlive(replies);
|
||||
|
||||
Assert.True(
|
||||
allocated < count * (long)maximumBytesPerReply,
|
||||
$"Completed replies allocated {allocated / (double)count:N1} bytes each.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriggerErrorWithoutAHandlerIsRetainedAndObservedByWaiters()
|
||||
{
|
||||
var reply = new AsyncReply<int>();
|
||||
var source = new InvalidOperationException("synchronous failure");
|
||||
|
||||
var triggerException = Record.Exception(() => reply.TriggerError(source));
|
||||
|
||||
Assert.Null(triggerException);
|
||||
Assert.False(reply.Ready);
|
||||
Assert.True(reply.Failed);
|
||||
var stored = Assert.IsType<AsyncException>(reply.Exception);
|
||||
Assert.Same(source, stored.InnerException);
|
||||
Assert.Same(stored, Assert.Throws<AsyncException>(() => reply.Wait()));
|
||||
Assert.Same(stored, Assert.Throws<AsyncException>(() => reply.Wait(10)));
|
||||
|
||||
AsyncException? observed = null;
|
||||
reply.Error(error => observed = error);
|
||||
Assert.Same(stored, observed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CustomAsyncBuilderRetainsAnErrorThrownBeforeTheFirstAwait()
|
||||
{
|
||||
AsyncReply<int>? reply = null;
|
||||
|
||||
var creationException = Record.Exception(() => reply = FailBeforeFirstAwait(fail: true));
|
||||
|
||||
Assert.Null(creationException);
|
||||
Assert.NotNull(reply);
|
||||
Assert.True(reply!.Failed);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => _ = await reply);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CustomAsyncBuilderPreservesAsyncExceptionAfterSuspension()
|
||||
{
|
||||
var gate = new AsyncReply();
|
||||
var reply = FailAfterFirstAwait(gate);
|
||||
var observation = AwaitAsTask(reply);
|
||||
|
||||
gate.Trigger(null!);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<AsyncException>(() => observation);
|
||||
Assert.IsType<InvalidOperationException>(exception.InnerException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CustomAsyncBuilderPublishesSuspensionBeforeInlineContinuation()
|
||||
{
|
||||
AsyncReply<int>? reply = null;
|
||||
|
||||
var creationException = Record.Exception(() => reply = FailAfterInlineCompletion());
|
||||
|
||||
Assert.Null(creationException);
|
||||
var exception = await Assert.ThrowsAsync<AsyncException>(async () => _ = await reply!);
|
||||
Assert.IsType<InvalidOperationException>(exception.InnerException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamErrorsReachTheBaseTerminalStateWithoutAnErrorHandler()
|
||||
{
|
||||
static AsyncReply CompletedControl() => new AsyncReply((object)null!);
|
||||
|
||||
var stream = new AsyncStreamReply<int>(
|
||||
StreamMode.Push,
|
||||
CompletedControl,
|
||||
CompletedControl,
|
||||
CompletedControl,
|
||||
CompletedControl);
|
||||
var expected = new AsyncException(ErrorType.Exception, 0, "stream failed");
|
||||
|
||||
stream.TriggerError(expected);
|
||||
|
||||
Assert.True(stream.Failed);
|
||||
Assert.Same(expected, stream.Exception);
|
||||
Assert.Same(expected, Assert.Throws<AsyncException>(() => stream.Wait()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void TerminalStateWakesEverySynchronousWaiter(bool fail)
|
||||
{
|
||||
const int waiterCount = 8;
|
||||
var reply = new AsyncReply<int>();
|
||||
var results = new int[waiterCount];
|
||||
var errors = new Exception?[waiterCount];
|
||||
var threads = new Thread[waiterCount];
|
||||
|
||||
for (var i = 0; i < threads.Length; i++)
|
||||
{
|
||||
var index = i;
|
||||
threads[i] = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
results[index] = reply.Wait();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
errors[index] = exception;
|
||||
}
|
||||
})
|
||||
{
|
||||
IsBackground = true
|
||||
};
|
||||
threads[i].Start();
|
||||
}
|
||||
|
||||
var allWaiting = SpinWait.SpinUntil(
|
||||
() => threads.All(thread =>
|
||||
(thread.ThreadState & System.Threading.ThreadState.WaitSleepJoin) != 0),
|
||||
millisecondsTimeout: 5000);
|
||||
|
||||
if (fail)
|
||||
reply.TriggerError(new InvalidOperationException("failed"));
|
||||
else
|
||||
reply.Trigger(73);
|
||||
|
||||
var allJoined = threads.Select(thread => thread.Join(2000)).ToArray();
|
||||
|
||||
Assert.True(allWaiting, "Not all test threads reached the blocking wait.");
|
||||
Assert.All(allJoined, joined => Assert.True(joined, "A synchronous waiter was not released."));
|
||||
|
||||
if (fail)
|
||||
Assert.All(errors, error => Assert.IsType<AsyncException>(error));
|
||||
else
|
||||
{
|
||||
Assert.All(results, value => Assert.Equal(73, value));
|
||||
Assert.All(errors, Assert.Null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompletionCallbacksRunOutsideTheReplyLock()
|
||||
{
|
||||
var reply = new AsyncReply<int>();
|
||||
reply.Then(_ =>
|
||||
{
|
||||
var concurrentRegistration = Task.Run(() => reply.Then(__ => { }));
|
||||
if (!concurrentRegistration.Wait(2000))
|
||||
throw new TimeoutException("Concurrent callback registration was blocked by the completion callback.");
|
||||
});
|
||||
|
||||
Assert.Null(Record.Exception(() => reply.Trigger(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorRegistrationRacingWithFailureInvokesExactlyOnce()
|
||||
{
|
||||
for (var iteration = 0; iteration < 500; iteration++)
|
||||
{
|
||||
var reply = new AsyncReply<int>();
|
||||
var expected = new AsyncException(ErrorType.Exception, 0, "failed");
|
||||
AsyncException? observed = null;
|
||||
var calls = 0;
|
||||
|
||||
Parallel.Invoke(
|
||||
() => reply.Error(error =>
|
||||
{
|
||||
observed = error;
|
||||
Interlocked.Increment(ref calls);
|
||||
}),
|
||||
() => reply.TriggerError(expected));
|
||||
|
||||
Assert.Equal(1, Volatile.Read(ref calls));
|
||||
Assert.Same(expected, observed);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AwaiterContinuationIsNotLostDuringCompletionRace()
|
||||
{
|
||||
for (var iteration = 0; iteration < 500; iteration++)
|
||||
{
|
||||
var reply = new AsyncReply<int>();
|
||||
var awaiter = reply.GetAwaiter();
|
||||
using var continued = new ManualResetEventSlim(false);
|
||||
|
||||
Parallel.Invoke(
|
||||
() => awaiter.OnCompleted(continued.Set),
|
||||
() => reply.Trigger(iteration));
|
||||
|
||||
Assert.True(continued.Wait(2000), "The awaiter continuation was lost.");
|
||||
Assert.True(awaiter.IsCompleted);
|
||||
Assert.Equal(iteration, awaiter.GetResult());
|
||||
}
|
||||
}
|
||||
|
||||
private static object? GetBaseField(AsyncReply reply, string name)
|
||||
=> typeof(AsyncReply)
|
||||
.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?.GetValue(reply);
|
||||
|
||||
private static async AsyncReply<int> FailBeforeFirstAwait(bool fail)
|
||||
{
|
||||
if (fail)
|
||||
throw new InvalidOperationException("failed before await");
|
||||
|
||||
await Task.Yield();
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static async AsyncReply<int> FailAfterFirstAwait(AsyncReply gate)
|
||||
{
|
||||
await gate;
|
||||
throw new InvalidOperationException("failed after await");
|
||||
}
|
||||
|
||||
private static async Task<int> AwaitAsTask(AsyncReply<int> reply) => await reply;
|
||||
|
||||
private static async AsyncReply<int> FailAfterInlineCompletion()
|
||||
{
|
||||
await new InlineCompletion();
|
||||
throw new InvalidOperationException("failed after inline continuation");
|
||||
}
|
||||
|
||||
private readonly struct InlineCompletion
|
||||
{
|
||||
public Awaiter GetAwaiter() => new Awaiter();
|
||||
|
||||
public readonly struct Awaiter : INotifyCompletion
|
||||
{
|
||||
public bool IsCompleted => false;
|
||||
|
||||
public void GetResult()
|
||||
{
|
||||
}
|
||||
|
||||
public void OnCompleted(Action continuation) => continuation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Esiur.Data;
|
||||
using Esiur.Net.Http;
|
||||
using Esiur.Net.Packets.Http;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public class HttpHardeningTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Content-Length: 1\r\nContent-Length: 1\r\n")]
|
||||
[InlineData("Content-Length: 1\r\nContent-Length: 2\r\n")]
|
||||
public void Request_RejectsDuplicateContentLength(string framingHeaders)
|
||||
{
|
||||
var data = RequestBytes("POST", framingHeaders, "x");
|
||||
|
||||
Assert.Throws<InvalidDataException>(() =>
|
||||
new HttpRequestPacket().Parse(data, 0, (uint)data.Length));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Transfer-Encoding: chunked\r\n")]
|
||||
[InlineData("Transfer-Encoding: identity\r\nContent-Length: 1\r\n")]
|
||||
public void Request_RejectsUnsupportedTransferEncoding(string framingHeaders)
|
||||
{
|
||||
var data = RequestBytes("POST", framingHeaders, "x");
|
||||
|
||||
Assert.Throws<InvalidDataException>(() =>
|
||||
new HttpRequestPacket().Parse(data, 0, (uint)data.Length));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PUT")]
|
||||
[InlineData("GET")]
|
||||
[InlineData("DELETE")]
|
||||
public void Request_ConsumesDeclaredBodiesForEveryMethod(string method)
|
||||
{
|
||||
var data = RequestBytes(
|
||||
method,
|
||||
"Content-Type: application/octet-stream\r\nContent-Length: 3\r\n",
|
||||
"abc");
|
||||
var packet = new HttpRequestPacket();
|
||||
|
||||
Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length));
|
||||
Assert.Equal("abc", Encoding.ASCII.GetString(packet.Message));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Request_AllowsPostWithoutABodyOrContentLength()
|
||||
{
|
||||
var data = RequestBytes("POST", string.Empty, string.Empty);
|
||||
var packet = new HttpRequestPacket();
|
||||
|
||||
Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length));
|
||||
Assert.Empty(packet.PostForms);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Request_EnforcesHeaderCountBeforeSplittingHeaders()
|
||||
{
|
||||
var data = RequestBytes("GET", "X-One: 1\r\nX-Two: 2\r\n", string.Empty);
|
||||
var packet = new HttpRequestPacket { MaximumHeaderCount = 1 };
|
||||
|
||||
Assert.Throws<ParserLimitException>(() =>
|
||||
packet.Parse(data, 0, (uint)data.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UrlEncodedForm_EnforcesFieldKeyAndValueLimits()
|
||||
{
|
||||
AssertFormLimit("a=1&b=2", packet => packet.MaximumFormFields = 1);
|
||||
AssertFormLimit("long=1", packet => packet.MaximumFormKeyLength = 3);
|
||||
AssertFormLimit("a=long", packet => packet.MaximumFormValueLength = 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UrlEncodedForm_CombinesUnkeyedFieldsWithinTheConfiguredBound()
|
||||
{
|
||||
const string body = "first&second&third";
|
||||
var data = RequestBytes(
|
||||
"POST",
|
||||
$"Content-Type: application/x-www-form-urlencoded\r\nContent-Length: {body.Length}\r\n",
|
||||
body);
|
||||
var packet = new HttpRequestPacket { MaximumFormValueLength = body.Length };
|
||||
|
||||
Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length));
|
||||
Assert.Equal(body, packet.PostForms["unknown"]);
|
||||
|
||||
packet = new HttpRequestPacket { MaximumFormValueLength = body.Length - 1 };
|
||||
Assert.Throws<ParserLimitException>(() =>
|
||||
packet.Parse(data, 0, (uint)data.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipartForm_EnforcesPartLength()
|
||||
{
|
||||
const string boundary = "test-boundary";
|
||||
const string body =
|
||||
"--test-boundary\r\n" +
|
||||
"Content-Disposition: form-data; name=\"value\"\r\n\r\n" +
|
||||
"payload\r\n" +
|
||||
"--test-boundary--\r\n";
|
||||
var data = RequestBytes(
|
||||
"POST",
|
||||
$"Content-Type: multipart/form-data; boundary=\"{boundary}\"\r\n" +
|
||||
$"Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\n",
|
||||
body);
|
||||
var packet = new HttpRequestPacket { MaximumMultipartPartLength = 8 };
|
||||
|
||||
Assert.Throws<ParserLimitException>(() =>
|
||||
packet.Parse(data, 0, (uint)data.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipartForm_ParsesQuotedBoundaryIncrementally()
|
||||
{
|
||||
const string boundary = "test-boundary";
|
||||
const string body =
|
||||
"--test-boundary\r\n" +
|
||||
"Content-Disposition: form-data; name=\"first\"\r\n\r\n" +
|
||||
"one\r\n" +
|
||||
"--test-boundary\r\n" +
|
||||
"Content-Disposition: form-data; name=\"second\"\r\n\r\n" +
|
||||
"two\r\n" +
|
||||
"--test-boundary--\r\n";
|
||||
var data = RequestBytes(
|
||||
"POST",
|
||||
$"Content-Type: multipart/form-data; boundary=\"{boundary}\"\r\n" +
|
||||
$"Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\n",
|
||||
body);
|
||||
var packet = new HttpRequestPacket();
|
||||
|
||||
Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length));
|
||||
Assert.Equal("one", packet.PostForms["first"]);
|
||||
Assert.Equal("two", packet.PostForms["second"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Content-Length: 1\r\nContent-Length: 1\r\n")]
|
||||
[InlineData("Transfer-Encoding: chunked\r\n")]
|
||||
[InlineData("Transfer-Encoding: identity\r\nContent-Length: 1\r\n")]
|
||||
public void Response_RejectsAmbiguousOrUnsupportedFraming(string framingHeaders)
|
||||
{
|
||||
var data = Encoding.ASCII.GetBytes(
|
||||
"HTTP/1.1 200 OK\r\n" + framingHeaders + "\r\nx");
|
||||
|
||||
Assert.Throws<InvalidDataException>(() =>
|
||||
new HttpResponsePacket().Parse(data, 0, (uint)data.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cookie_RoundTripsSecureAndSameSiteAttributes()
|
||||
{
|
||||
var cookie = new HttpCookie("sid", "value")
|
||||
{
|
||||
Path = "/",
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = HttpCookieSameSite.Strict,
|
||||
};
|
||||
var serialized = cookie.ToString();
|
||||
Assert.Contains("; Secure", serialized, StringComparison.Ordinal);
|
||||
Assert.Contains("; SameSite=Strict", serialized, StringComparison.Ordinal);
|
||||
|
||||
var data = Encoding.ASCII.GetBytes(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nSet-Cookie: " + serialized + "\r\n\r\n");
|
||||
var response = new HttpResponsePacket();
|
||||
|
||||
Assert.Equal(data.Length, response.Parse(data, 0, (uint)data.Length));
|
||||
Assert.True(response.Cookies[0].Secure);
|
||||
Assert.Equal(HttpCookieSameSite.Strict, response.Cookies[0].SameSite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Response_ComposeUsesAnExplicitOutboundLimitOnly()
|
||||
{
|
||||
var response = new HttpResponsePacket
|
||||
{
|
||||
MaximumContentLength = 1,
|
||||
Message = new byte[] { 1, 2 },
|
||||
};
|
||||
|
||||
Assert.True(response.Compose(HttpComposeOption.DataOnly));
|
||||
Assert.Equal(response.Message, response.Data);
|
||||
|
||||
response.MaximumComposedContentLength = 1;
|
||||
Assert.Throws<ParserLimitException>(() =>
|
||||
response.Compose(HttpComposeOption.DataOnly));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_TimerStartsAndServerRemovesExpiredSession()
|
||||
{
|
||||
var server = new HttpServer();
|
||||
var ended = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var session = server.CreateSession("expiring", 1);
|
||||
session.OnEnd += _ => ended.TrySetResult(true);
|
||||
|
||||
await ended.Task.WaitAsync(TimeSpan.FromSeconds(3));
|
||||
|
||||
var sessions = (IDictionary)typeof(HttpServer)
|
||||
.GetField("sessions", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.GetValue(server)!;
|
||||
Assert.Empty(sessions);
|
||||
|
||||
// Destruction remains safe after the expiry callback has already disposed the timer.
|
||||
session.Destroy();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session_WithoutATimerCanBeDestroyedIdempotently()
|
||||
{
|
||||
var session = new HttpSession();
|
||||
|
||||
session.Destroy();
|
||||
session.Destroy();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitlyDestroyedSession_IsRemovedFromServer()
|
||||
{
|
||||
var server = new HttpServer();
|
||||
var session = server.CreateSession("destroyed", 0);
|
||||
|
||||
session.Destroy();
|
||||
|
||||
var sessions = (IDictionary)typeof(HttpServer)
|
||||
.GetField("sessions", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.GetValue(server)!;
|
||||
Assert.Empty(sessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InternalServerErrorPage_HtmlEncodesExceptionText()
|
||||
{
|
||||
var page = HttpConnection.FormatError500Page("<script>alert('x')</script>&");
|
||||
|
||||
Assert.DoesNotContain("<script>", page, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("<script>", page, StringComparison.Ordinal);
|
||||
Assert.Contains("&", page, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void AssertFormLimit(string body, Action<HttpRequestPacket> configure)
|
||||
{
|
||||
var data = RequestBytes(
|
||||
"POST",
|
||||
$"Content-Type: application/x-www-form-urlencoded\r\nContent-Length: {body.Length}\r\n",
|
||||
body);
|
||||
var packet = new HttpRequestPacket();
|
||||
configure(packet);
|
||||
|
||||
Assert.Throws<ParserLimitException>(() =>
|
||||
packet.Parse(data, 0, (uint)data.Length));
|
||||
}
|
||||
|
||||
private static byte[] RequestBytes(string method, string headers, string body)
|
||||
=> Encoding.ASCII.GetBytes($"{method} / HTTP/1.1\r\n{headers}\r\n{body}");
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Esiur.Core;
|
||||
using Esiur.Data;
|
||||
using Esiur.Net;
|
||||
using Esiur.Net.Http;
|
||||
using Esiur.Net.Packets.Http;
|
||||
using Esiur.Net.Packets.WebSocket;
|
||||
using Esiur.Net.Sockets;
|
||||
using Esiur.Resource;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public class HttpWebSocketConnectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Handshake_RequiresGetHttp11TokensVersionAndCanonical16ByteKey()
|
||||
{
|
||||
Assert.True(HttpConnection.IsWebsocketRequest(ValidHandshake()));
|
||||
|
||||
var request = ValidHandshake();
|
||||
request.Method = Esiur.Net.Packets.Http.HttpMethod.POST;
|
||||
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
||||
|
||||
request = ValidHandshake();
|
||||
request.Version = "HTTP/1.0";
|
||||
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
||||
|
||||
request = ValidHandshake();
|
||||
request.RawMethod = "get";
|
||||
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
||||
|
||||
request = ValidHandshake();
|
||||
request.Headers["Connection"] = "keep-alive, not-an-upgrade";
|
||||
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
||||
|
||||
request = ValidHandshake();
|
||||
request.Headers["Upgrade"] = "notwebsocket";
|
||||
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
||||
|
||||
request = ValidHandshake();
|
||||
request.Headers["Sec-WebSocket-Version"] = "12";
|
||||
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
||||
|
||||
request = ValidHandshake();
|
||||
request.Headers["Sec-WebSocket-Key"] = "not-base64";
|
||||
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
||||
|
||||
request = ValidHandshake();
|
||||
request.Headers["Sec-WebSocket-Key"] = Convert.ToBase64String(new byte[15]);
|
||||
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Upgrade_SelectsOnlyAnExplicitlySupportedSubprotocol()
|
||||
{
|
||||
var request = ValidHandshake();
|
||||
request.Headers["Sec-WebSocket-Protocol"] = "chat, superchat";
|
||||
var response = new HttpResponsePacket();
|
||||
response.Headers["Sec-WebSocket-Protocol"] = "untrusted";
|
||||
|
||||
Assert.True(HttpConnection.Upgrade(request, response));
|
||||
Assert.Null(response.Headers["Sec-WebSocket-Protocol"]);
|
||||
Assert.Equal("websocket", response.Headers["Upgrade"]);
|
||||
Assert.Equal("Upgrade", response.Headers["Connection"]);
|
||||
|
||||
response = new HttpResponsePacket();
|
||||
Assert.True(HttpConnection.Upgrade(
|
||||
request,
|
||||
response,
|
||||
new[] { "superchat", "chat" },
|
||||
out var selected));
|
||||
Assert.Equal("superchat", selected);
|
||||
Assert.Equal("superchat", response.Headers["Sec-WebSocket-Protocol"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedAdvertisedUpgrade_IsRejectedBeforeFiltersOrRoutesRun()
|
||||
{
|
||||
var server = new HttpServer();
|
||||
var (connection, socket, filter) = CreateHttpConnection(server);
|
||||
var request = Encoding.ASCII.GetBytes(
|
||||
"GET / HTTP/1.1\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Sec-WebSocket-Version: 13\r\n" +
|
||||
"Sec-WebSocket-Key: invalid\r\n\r\n");
|
||||
|
||||
Receive(connection, socket, request);
|
||||
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
Assert.False(connection.WSMode);
|
||||
Assert.Equal(0, filter.ExecutionCount);
|
||||
Assert.StartsWith("HTTP/1.1 400 Bad Request", Encoding.ASCII.GetString(Assert.Single(socket.Sent)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidUpgrade_UsesServerSubprotocolConfiguration()
|
||||
{
|
||||
var server = new HttpServer
|
||||
{
|
||||
WebSocketSubprotocols = new[] { "superchat" }
|
||||
};
|
||||
var (connection, socket, filter) = CreateHttpConnection(server);
|
||||
var request = Encoding.ASCII.GetBytes(
|
||||
"GET / HTTP/1.1\r\n" +
|
||||
"Connection: keep-alive, Upgrade\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Sec-WebSocket-Version: 13\r\n" +
|
||||
$"Sec-WebSocket-Key: {Convert.ToBase64String(new byte[16])}\r\n" +
|
||||
"Sec-WebSocket-Protocol: chat, superchat\r\n\r\n");
|
||||
|
||||
Receive(connection, socket, request);
|
||||
|
||||
Assert.Equal(SocketState.Established, socket.State);
|
||||
Assert.True(connection.WSMode);
|
||||
Assert.Equal("superchat", connection.WebSocketSubprotocol);
|
||||
Assert.Equal(1, filter.ExecutionCount);
|
||||
Assert.Contains(
|
||||
"sec-websocket-protocol: superchat\r\n",
|
||||
Encoding.ASCII.GetString(Assert.Single(socket.Sent)),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidUpgrade_DrainsFramesCoalescedWithTheHttpHandshake()
|
||||
{
|
||||
var (connection, socket, filter) = CreateHttpConnection();
|
||||
var handshake = Encoding.ASCII.GetBytes(
|
||||
"GET / HTTP/1.1\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Sec-WebSocket-Version: 13\r\n" +
|
||||
$"Sec-WebSocket-Key: {Convert.ToBase64String(new byte[16])}\r\n\r\n");
|
||||
var input = Concat(
|
||||
handshake,
|
||||
Frame(WebsocketPacket.WSOpcode.BinaryFrame, true, true, new byte[] { 1 }),
|
||||
Frame(WebsocketPacket.WSOpcode.BinaryFrame, true, true, new byte[] { 2 }));
|
||||
|
||||
Receive(connection, socket, input);
|
||||
|
||||
Assert.True(connection.WSMode);
|
||||
Assert.Equal(3, filter.ExecutionCount);
|
||||
Assert.Collection(
|
||||
filter.Messages,
|
||||
message => Assert.Equal(new byte[] { 1 }, message.Payload),
|
||||
message => Assert.Equal(new byte[] { 2 }, message.Payload));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInWebSocket_SendAlwaysRecomposesServerFramesAsUnmasked()
|
||||
{
|
||||
var (connection, socket, _) = CreateWebSocketConnection();
|
||||
var packet = new WebsocketPacket
|
||||
{
|
||||
FIN = true,
|
||||
Opcode = WebsocketPacket.WSOpcode.BinaryFrame,
|
||||
Mask = true,
|
||||
MaskKey = new byte[] { 1, 2, 3, 4 },
|
||||
Message = new byte[] { 7, 8, 9 }
|
||||
};
|
||||
packet.Compose();
|
||||
|
||||
connection.Send(packet);
|
||||
|
||||
var sent = ParseServerFrame(Assert.Single(socket.Sent));
|
||||
Assert.False(sent.Mask);
|
||||
Assert.Equal(new byte[] { 7, 8, 9 }, sent.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInWebSocket_ReassemblesMessagesHandlesPingAndDrainsCoalescedFrames()
|
||||
{
|
||||
var (connection, socket, filter) = CreateWebSocketConnection();
|
||||
var frames = Concat(
|
||||
Frame(WebsocketPacket.WSOpcode.TextFrame, false, true, Encoding.UTF8.GetBytes("hel")),
|
||||
Frame(WebsocketPacket.WSOpcode.Ping, true, true, Encoding.ASCII.GetBytes("p")),
|
||||
Frame(WebsocketPacket.WSOpcode.ContinuationFrame, true, true, Encoding.UTF8.GetBytes("lo")),
|
||||
Frame(WebsocketPacket.WSOpcode.BinaryFrame, true, true, new byte[] { 1, 2 }));
|
||||
|
||||
Receive(connection, socket, frames);
|
||||
|
||||
Assert.Equal(SocketState.Established, socket.State);
|
||||
Assert.Collection(
|
||||
filter.Messages,
|
||||
message =>
|
||||
{
|
||||
Assert.Equal(WebsocketPacket.WSOpcode.TextFrame, message.Opcode);
|
||||
Assert.Equal("hello", Encoding.UTF8.GetString(message.Payload));
|
||||
},
|
||||
message =>
|
||||
{
|
||||
Assert.Equal(WebsocketPacket.WSOpcode.BinaryFrame, message.Opcode);
|
||||
Assert.Equal(new byte[] { 1, 2 }, message.Payload);
|
||||
});
|
||||
|
||||
var pong = Assert.Single(socket.Sent);
|
||||
var parsedPong = ParseServerFrame(pong);
|
||||
Assert.Equal(WebsocketPacket.WSOpcode.Pong, parsedPong.Opcode);
|
||||
Assert.Equal("p", Encoding.ASCII.GetString(parsedPong.Message));
|
||||
Assert.Equal(WebsocketPacket.WSOpcode.BinaryFrame, connection.WSRequest.Opcode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInWebSocket_RetainsAnIncompleteFrameWithoutLosingTheNextFrame()
|
||||
{
|
||||
var (connection, socket, filter) = CreateWebSocketConnection();
|
||||
var first = Frame(
|
||||
WebsocketPacket.WSOpcode.BinaryFrame,
|
||||
true,
|
||||
true,
|
||||
new byte[] { 1, 2, 3, 4 });
|
||||
var second = Frame(
|
||||
WebsocketPacket.WSOpcode.BinaryFrame,
|
||||
true,
|
||||
true,
|
||||
new byte[] { 5, 6 });
|
||||
var buffer = new NetworkBuffer();
|
||||
var split = first.Length - 2;
|
||||
|
||||
buffer.Write(first, 0, (uint)split);
|
||||
connection.NetworkReceive(socket, buffer);
|
||||
Assert.True(buffer.Protected);
|
||||
Assert.Empty(filter.Messages);
|
||||
|
||||
buffer.Write(Concat(first.Skip(split).ToArray(), second));
|
||||
connection.NetworkReceive(socket, buffer);
|
||||
|
||||
Assert.False(buffer.Protected);
|
||||
Assert.Collection(
|
||||
filter.Messages,
|
||||
message => Assert.Equal(new byte[] { 1, 2, 3, 4 }, message.Payload),
|
||||
message => Assert.Equal(new byte[] { 5, 6 }, message.Payload));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInWebSocket_RejectsUnmaskedClientFramesWithProtocolClose()
|
||||
{
|
||||
var (connection, socket, filter) = CreateWebSocketConnection();
|
||||
|
||||
Receive(connection, socket, Frame(
|
||||
WebsocketPacket.WSOpcode.BinaryFrame,
|
||||
true,
|
||||
false,
|
||||
new byte[] { 1 }));
|
||||
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
Assert.Empty(filter.Messages);
|
||||
AssertCloseCode(socket, 1002);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInWebSocket_RejectsInvalidUtf8AcrossFragments()
|
||||
{
|
||||
var (connection, socket, filter) = CreateWebSocketConnection();
|
||||
|
||||
Receive(connection, socket, Concat(
|
||||
Frame(WebsocketPacket.WSOpcode.TextFrame, false, true, new byte[] { 0xC3 }),
|
||||
Frame(WebsocketPacket.WSOpcode.ContinuationFrame, true, true, new byte[] { 0x28 })));
|
||||
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
Assert.Empty(filter.Messages);
|
||||
AssertCloseCode(socket, 1007);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInWebSocket_EnforcesAggregateFragmentLimit()
|
||||
{
|
||||
var server = new HttpServer { MaximumWebSocketMessageLength = 4 };
|
||||
var (connection, socket, filter) = CreateWebSocketConnection(server);
|
||||
|
||||
Receive(connection, socket, Concat(
|
||||
Frame(WebsocketPacket.WSOpcode.BinaryFrame, false, true, new byte[] { 1, 2, 3 }),
|
||||
Frame(WebsocketPacket.WSOpcode.ContinuationFrame, true, true, new byte[] { 4, 5 })));
|
||||
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
Assert.Empty(filter.Messages);
|
||||
AssertCloseCode(socket, 1009);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInWebSocket_EchoesAValidClosePayloadThenCloses()
|
||||
{
|
||||
var (connection, socket, _) = CreateWebSocketConnection();
|
||||
var payload = new byte[] { 0x03, 0xE8 };
|
||||
|
||||
Receive(connection, socket, Frame(
|
||||
WebsocketPacket.WSOpcode.ConnectionClose,
|
||||
true,
|
||||
true,
|
||||
payload));
|
||||
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
var close = ParseServerFrame(Assert.Single(socket.Sent));
|
||||
Assert.Equal(WebsocketPacket.WSOpcode.ConnectionClose, close.Opcode);
|
||||
Assert.Equal(payload, close.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInWebSocket_WaitsForCloseFrameSendBeforeClosingTransport()
|
||||
{
|
||||
var pendingSend = new AsyncReply<bool>();
|
||||
var server = new HttpServer();
|
||||
var filter = new CaptureFilter();
|
||||
SetFilters(server, filter);
|
||||
var socket = new TestSocket(pendingSend);
|
||||
var connection = new HttpConnection { Server = server, WSMode = true };
|
||||
connection.Assign(socket);
|
||||
|
||||
Receive(connection, socket, Frame(
|
||||
WebsocketPacket.WSOpcode.ConnectionClose,
|
||||
true,
|
||||
true,
|
||||
new byte[] { 0x03, 0xE8 }));
|
||||
|
||||
Assert.Equal(SocketState.Established, socket.State);
|
||||
Assert.Single(socket.Sent);
|
||||
|
||||
pendingSend.Trigger(true);
|
||||
Assert.Equal(SocketState.Closed, socket.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExceptionDetails_AreGenericByDefaultAndEncodedWhenOptedIn()
|
||||
{
|
||||
var server = new HttpServer();
|
||||
var connection = new HttpConnection { Server = server };
|
||||
var exception = new InvalidOperationException("<script>secret</script>");
|
||||
|
||||
var genericPage = connection.FormatError500Page(exception);
|
||||
Assert.Contains("An internal server error occurred.", genericPage);
|
||||
Assert.DoesNotContain("secret", genericPage, StringComparison.Ordinal);
|
||||
|
||||
server.ExposeExceptionDetails = true;
|
||||
var detailedPage = connection.FormatError500Page(exception);
|
||||
Assert.Contains("<script>secret</script>", detailedPage);
|
||||
Assert.DoesNotContain("<script>", detailedPage, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionLookup_RestoresRefreshesAndStopsReturningDestroyedSessions()
|
||||
{
|
||||
var server = new HttpServer();
|
||||
var session = server.CreateSession("known-session", 0);
|
||||
var initialAction = session.LastAction;
|
||||
var request = new HttpRequestPacket { Cookies = new StringKeyList() };
|
||||
request.Cookies["SID"] = session.Id;
|
||||
var connection = new HttpConnection
|
||||
{
|
||||
Server = server,
|
||||
Request = request
|
||||
};
|
||||
|
||||
Assert.True(server.TryGetSession(session.Id, out var found));
|
||||
Assert.Same(session, found);
|
||||
|
||||
connection.RestoreSessionFromRequest();
|
||||
Assert.Same(session, connection.Session);
|
||||
Assert.True(session.LastAction >= initialAction);
|
||||
|
||||
session.Destroy();
|
||||
Assert.False(server.TryGetSession(session.Id, out _));
|
||||
connection.RestoreSessionFromRequest();
|
||||
Assert.Null(connection.Session);
|
||||
}
|
||||
|
||||
private static HttpRequestPacket ValidHandshake()
|
||||
{
|
||||
var headers = new StringKeyList();
|
||||
headers["Connection"] = "keep-alive, Upgrade";
|
||||
headers["Upgrade"] = "websocket";
|
||||
headers["Sec-WebSocket-Version"] = "13";
|
||||
headers["Sec-WebSocket-Key"] = Convert.ToBase64String(new byte[16]);
|
||||
return new HttpRequestPacket
|
||||
{
|
||||
Method = Esiur.Net.Packets.Http.HttpMethod.GET,
|
||||
Version = "HTTP/1.1",
|
||||
Headers = headers,
|
||||
URL = "/"
|
||||
};
|
||||
}
|
||||
|
||||
private static (HttpConnection Connection, TestSocket Socket, CaptureFilter Filter)
|
||||
CreateWebSocketConnection(HttpServer? server = null)
|
||||
{
|
||||
var result = CreateHttpConnection(server);
|
||||
result.Connection.WSMode = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static (HttpConnection Connection, TestSocket Socket, CaptureFilter Filter)
|
||||
CreateHttpConnection(HttpServer? server = null)
|
||||
{
|
||||
server ??= new HttpServer();
|
||||
var filter = new CaptureFilter();
|
||||
SetFilters(server, filter);
|
||||
|
||||
var socket = new TestSocket();
|
||||
var connection = new HttpConnection
|
||||
{
|
||||
Server = server
|
||||
};
|
||||
connection.Assign(socket);
|
||||
return (connection, socket, filter);
|
||||
}
|
||||
|
||||
private static void SetFilters(HttpServer server, params HttpFilter[] filters)
|
||||
=> typeof(HttpServer)
|
||||
.GetField("filters", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.SetValue(server, filters);
|
||||
|
||||
private static void Receive(
|
||||
HttpConnection connection,
|
||||
TestSocket socket,
|
||||
byte[] message)
|
||||
{
|
||||
var buffer = new NetworkBuffer();
|
||||
buffer.Write(message);
|
||||
connection.NetworkReceive(socket, buffer);
|
||||
}
|
||||
|
||||
private static byte[] Frame(
|
||||
WebsocketPacket.WSOpcode opcode,
|
||||
bool final,
|
||||
bool masked,
|
||||
byte[] payload)
|
||||
{
|
||||
var packet = new WebsocketPacket
|
||||
{
|
||||
FIN = final,
|
||||
Opcode = opcode,
|
||||
Mask = masked,
|
||||
MaskKey = new byte[] { 1, 2, 3, 4 },
|
||||
Message = payload
|
||||
};
|
||||
packet.Compose();
|
||||
return packet.Data;
|
||||
}
|
||||
|
||||
private static byte[] Concat(params byte[][] messages)
|
||||
{
|
||||
var length = messages.Sum(message => message.Length);
|
||||
var result = new byte[length];
|
||||
var offset = 0;
|
||||
foreach (var message in messages)
|
||||
{
|
||||
Buffer.BlockCopy(message, 0, result, offset, message.Length);
|
||||
offset += message.Length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static WebsocketPacket ParseServerFrame(byte[] data)
|
||||
{
|
||||
var packet = new WebsocketPacket { ExpectedMask = false };
|
||||
Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length));
|
||||
return packet;
|
||||
}
|
||||
|
||||
private static void AssertCloseCode(TestSocket socket, int expected)
|
||||
{
|
||||
var close = ParseServerFrame(Assert.Single(socket.Sent));
|
||||
Assert.Equal(WebsocketPacket.WSOpcode.ConnectionClose, close.Opcode);
|
||||
Assert.Equal(expected, close.Message[0] << 8 | close.Message[1]);
|
||||
}
|
||||
|
||||
private sealed class CaptureFilter : HttpFilter
|
||||
{
|
||||
public List<(WebsocketPacket.WSOpcode Opcode, byte[] Payload)> Messages { get; } = new();
|
||||
public int ExecutionCount { get; private set; }
|
||||
|
||||
public override AsyncReply<bool> Execute(HttpConnection sender)
|
||||
{
|
||||
ExecutionCount++;
|
||||
var packet = sender.WSRequest;
|
||||
if (packet != null)
|
||||
Messages.Add((packet.Opcode, packet.Message.ToArray()));
|
||||
return new AsyncReply<bool>(true);
|
||||
}
|
||||
|
||||
public override AsyncReply<bool> Handle(
|
||||
ResourceOperation operation,
|
||||
IResourceContext? context = null) => new(true);
|
||||
}
|
||||
|
||||
private sealed class TestSocket : ISocket
|
||||
{
|
||||
private readonly AsyncReply<bool>? sendReply;
|
||||
|
||||
public TestSocket(AsyncReply<bool>? sendReply = null)
|
||||
=> this.sendReply = sendReply;
|
||||
|
||||
public event DestroyedEvent? OnDestroy;
|
||||
public SocketState State { get; private set; } = SocketState.Established;
|
||||
public INetworkReceiver<ISocket> Receiver { get; set; } = null!;
|
||||
public IPEndPoint RemoteEndPoint { get; } = new(IPAddress.Loopback, 12345);
|
||||
public IPEndPoint LocalEndPoint { get; } = new(IPAddress.Loopback, 54321);
|
||||
public List<byte[]> Sent { get; } = new();
|
||||
|
||||
public void Send(byte[] message) => Send(message, 0, message.Length);
|
||||
|
||||
public void Send(byte[] message, int offset, int length)
|
||||
{
|
||||
var copy = new byte[length];
|
||||
Buffer.BlockCopy(message, offset, copy, 0, length);
|
||||
Sent.Add(copy);
|
||||
}
|
||||
|
||||
public AsyncReply<bool> SendAsync(byte[] message, int offset, int length)
|
||||
{
|
||||
Send(message, offset, length);
|
||||
return sendReply ?? new AsyncReply<bool>(true);
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
if (State == SocketState.Closed)
|
||||
return;
|
||||
|
||||
State = SocketState.Closed;
|
||||
Receiver?.NetworkClose(this);
|
||||
}
|
||||
|
||||
public void Destroy()
|
||||
{
|
||||
Close();
|
||||
OnDestroy?.Invoke(this);
|
||||
OnDestroy = null;
|
||||
}
|
||||
|
||||
public AsyncReply<bool> Connect(string hostname, ushort port) => new(false);
|
||||
public bool Begin() => true;
|
||||
public AsyncReply<bool> BeginAsync() => new(true);
|
||||
public AsyncReply<ISocket> AcceptAsync() => new((ISocket)null!);
|
||||
public ISocket Accept() => null!;
|
||||
public void Hold() { }
|
||||
public void Unhold() { }
|
||||
}
|
||||
}
|
||||
@@ -114,14 +114,17 @@ internal sealed class IntegrationCluster : IAsyncDisposable
|
||||
bool allowEncryption = true,
|
||||
bool oneStepAuthentication = false,
|
||||
bool useWebSocket = false,
|
||||
bool mismatchedSessionKeys = false)
|
||||
bool mismatchedSessionKeys = false,
|
||||
bool allowAuthentication = true,
|
||||
bool registerServerAuthenticationProvider = true)
|
||||
{
|
||||
var port = Interlocked.Increment(ref _portCounter);
|
||||
|
||||
var serverWh = new Warehouse();
|
||||
serverWh.RegisterAuthenticationProvider(oneStepAuthentication
|
||||
? new OneStepAuthenticationProvider()
|
||||
: new TestServerAuthProvider());
|
||||
if (registerServerAuthenticationProvider)
|
||||
serverWh.RegisterAuthenticationProvider(oneStepAuthentication
|
||||
? new OneStepAuthenticationProvider()
|
||||
: new TestServerAuthProvider());
|
||||
if (encrypted || requireEncryption)
|
||||
serverWh.RegisterEncryptionProvider(new AesEncryptionProvider());
|
||||
|
||||
@@ -129,10 +132,9 @@ internal sealed class IntegrationCluster : IAsyncDisposable
|
||||
var server = await serverWh.Put("sys/server", new EpServer
|
||||
{
|
||||
Port = (ushort)port,
|
||||
AllowedAuthenticationProviders = new[]
|
||||
{
|
||||
oneStepAuthentication ? "one-step" : "hash",
|
||||
},
|
||||
AllowedAuthenticationProviders = allowAuthentication
|
||||
? new[] { oneStepAuthentication ? "one-step" : "hash" }
|
||||
: Array.Empty<string>(),
|
||||
AllowedEncryptionProviders = (encrypted || requireEncryption) && allowEncryption
|
||||
? new[] { AesEncryptionProvider.Name }
|
||||
: Array.Empty<string>(),
|
||||
|
||||
@@ -70,6 +70,32 @@ public class SessionHeadersIntegrationTests
|
||||
.WaitAsync(TimeSpan.FromSeconds(10)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisallowedAuthenticationProvider_FailsClosedPromptly()
|
||||
{
|
||||
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
await IntegrationCluster
|
||||
.StartAsync(
|
||||
_ => Task.CompletedTask,
|
||||
allowAuthentication: false)
|
||||
.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
|
||||
Assert.IsNotType<TimeoutException>(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllowlistedButUnregisteredAuthenticationProvider_FailsClosedPromptly()
|
||||
{
|
||||
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
await IntegrationCluster
|
||||
.StartAsync(
|
||||
_ => Task.CompletedTask,
|
||||
registerServerAuthenticationProvider: false)
|
||||
.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
|
||||
Assert.IsNotType<TimeoutException>(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddressBoundEncryption_DerivesMatchingCiphersAcrossPeers()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using Esiur.Net;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public class NetworkBufferTests
|
||||
{
|
||||
[Fact]
|
||||
public void FragmentedWritesAreConcatenatedAndReadResetsState()
|
||||
{
|
||||
var buffer = new NetworkBuffer();
|
||||
|
||||
buffer.Write(new byte[] { 1, 2 });
|
||||
buffer.Write(new byte[] { 99, 3, 4, 100 }, 1, 2);
|
||||
|
||||
Assert.Equal(4u, buffer.Available);
|
||||
Assert.True(buffer.CanRead);
|
||||
Assert.False(buffer.Protected);
|
||||
Assert.Equal(new byte[] { 1, 2, 3, 4 }, buffer.Read());
|
||||
Assert.Equal(0u, buffer.Available);
|
||||
Assert.False(buffer.CanRead);
|
||||
Assert.False(buffer.Protected);
|
||||
Assert.Null(buffer.Read());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HoldForRetainsPartialPacketUntilThreshold()
|
||||
{
|
||||
var buffer = new NetworkBuffer();
|
||||
|
||||
buffer.HoldFor(new byte[] { 1, 2 }, 5);
|
||||
Assert.True(buffer.Protected);
|
||||
Assert.False(buffer.CanRead);
|
||||
Assert.Null(buffer.Read());
|
||||
|
||||
buffer.Write(new byte[] { 3, 4 });
|
||||
Assert.True(buffer.Protected);
|
||||
Assert.Null(buffer.Read());
|
||||
|
||||
buffer.Write(new byte[] { 5 });
|
||||
Assert.False(buffer.Protected);
|
||||
Assert.True(buffer.CanRead);
|
||||
Assert.Equal(new byte[] { 1, 2, 3, 4, 5 }, buffer.Read());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HoldForPrependsPartialPacketToBufferedData()
|
||||
{
|
||||
var buffer = new NetworkBuffer();
|
||||
|
||||
buffer.Write(new byte[] { 3, 4 });
|
||||
buffer.HoldFor(new byte[] { 0, 1, 2, 9 }, 1, 2, 5);
|
||||
buffer.Write(new byte[] { 5 });
|
||||
|
||||
Assert.Equal(new byte[] { 1, 2, 3, 4, 5 }, buffer.Read());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProtectRetainsOnlyTheRemainingSlice()
|
||||
{
|
||||
var buffer = new NetworkBuffer();
|
||||
var source = new byte[] { 99, 1, 2 };
|
||||
|
||||
Assert.True(buffer.Protect(source, 1, 4));
|
||||
Assert.Equal(2u, buffer.Available);
|
||||
Assert.True(buffer.Protected);
|
||||
|
||||
buffer.Write(new byte[] { 3, 4 });
|
||||
Assert.Equal(new byte[] { 1, 2, 3, 4 }, buffer.Read());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProtectDoesNotRetainACompleteSlice()
|
||||
{
|
||||
var buffer = new NetworkBuffer();
|
||||
|
||||
Assert.False(buffer.Protect(new byte[] { 0, 1, 2, 3 }, 1, 3));
|
||||
Assert.Equal(0u, buffer.Available);
|
||||
Assert.Null(buffer.Read());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidRangesAndImpossibleThresholdsAreRejected()
|
||||
{
|
||||
var buffer = new NetworkBuffer();
|
||||
var source = new byte[4];
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => buffer.Write(null!));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => buffer.Write(source, 5, 0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => buffer.Write(source, 3, 2));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => buffer.Protect(source, 5, 6));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => buffer.Protect(source, 0, uint.MaxValue));
|
||||
Assert.Throws<Exception>(() => buffer.HoldFor(source, 0, 2, 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FragmentedGrowthHasLinearAllocationBound()
|
||||
{
|
||||
const int payloadLength = 512 * 1024;
|
||||
const int chunkLength = 1024;
|
||||
var chunk = new byte[chunkLength];
|
||||
|
||||
// Warm up the methods used below so JIT allocation is outside the measurement.
|
||||
var warmup = new NetworkBuffer();
|
||||
warmup.Write(chunk);
|
||||
warmup.Write(chunk);
|
||||
_ = warmup.Read();
|
||||
|
||||
var before = GC.GetAllocatedBytesForCurrentThread();
|
||||
var buffer = new NetworkBuffer();
|
||||
for (var offset = 0; offset < payloadLength; offset += chunkLength)
|
||||
buffer.Write(chunk);
|
||||
|
||||
var result = buffer.Read();
|
||||
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(payloadLength, result.Length);
|
||||
Assert.True(
|
||||
allocated < payloadLength * 8L,
|
||||
$"Fragmented buffering allocated {allocated:N0} bytes for a {payloadLength:N0}-byte payload.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using Esiur.Core;
|
||||
using Esiur.Data;
|
||||
using Esiur.Net;
|
||||
using Esiur.Net.Sockets;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public class NetworkLifecycleConcurrencyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task NetworkConnection_DrainsDistinctBuffersQueuedWhileParserIsBusy()
|
||||
{
|
||||
using var parserEntered = new ManualResetEventSlim();
|
||||
using var releaseParser = new ManualResetEventSlim();
|
||||
var connection = new BlockingConnection(parserEntered, releaseParser);
|
||||
var socket = new TestSocket();
|
||||
connection.Assign(socket);
|
||||
|
||||
var firstBuffer = BufferWith(1);
|
||||
var secondBuffer = BufferWith(2);
|
||||
var firstReceive = Task.Run(() => connection.NetworkReceive(socket, firstBuffer));
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(parserEntered.Wait(TimeSpan.FromSeconds(2)));
|
||||
|
||||
var secondReceive = Task.Run(() => connection.NetworkReceive(socket, secondBuffer));
|
||||
Assert.Same(secondReceive, await Task.WhenAny(
|
||||
secondReceive,
|
||||
Task.Delay(TimeSpan.FromSeconds(2))));
|
||||
|
||||
releaseParser.Set();
|
||||
var bothReceives = Task.WhenAll(firstReceive, secondReceive);
|
||||
Assert.Same(bothReceives, await Task.WhenAny(
|
||||
bothReceives,
|
||||
Task.Delay(TimeSpan.FromSeconds(2))));
|
||||
await bothReceives;
|
||||
|
||||
Assert.Equal(new byte[] { 1, 2 }, connection.Received.ToArray());
|
||||
Assert.Equal(0u, secondBuffer.Available);
|
||||
}
|
||||
finally
|
||||
{
|
||||
releaseParser.Set();
|
||||
await firstReceive;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NetworkConnection_DrainsNewSocketBufferAfterReplacementDuringReceive()
|
||||
{
|
||||
using var parserEntered = new ManualResetEventSlim();
|
||||
using var releaseParser = new ManualResetEventSlim();
|
||||
var connection = new BlockingConnection(parserEntered, releaseParser);
|
||||
var oldSocket = new TestSocket();
|
||||
var newSocket = new TestSocket();
|
||||
connection.Assign(oldSocket);
|
||||
|
||||
var firstReceive = Task.Run(() =>
|
||||
connection.NetworkReceive(oldSocket, BufferWith(1)));
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(parserEntered.Wait(TimeSpan.FromSeconds(2)));
|
||||
|
||||
var replacementBuffer = BufferWith(2);
|
||||
var replacementReceive = Task.Run(() =>
|
||||
{
|
||||
Assert.Same(oldSocket, connection.Unassign());
|
||||
connection.Assign(newSocket);
|
||||
connection.NetworkReceive(newSocket, replacementBuffer);
|
||||
});
|
||||
|
||||
Assert.Same(replacementReceive, await Task.WhenAny(
|
||||
replacementReceive,
|
||||
Task.Delay(TimeSpan.FromSeconds(2))));
|
||||
|
||||
releaseParser.Set();
|
||||
var bothReceives = Task.WhenAll(firstReceive, replacementReceive);
|
||||
Assert.Same(bothReceives, await Task.WhenAny(
|
||||
bothReceives,
|
||||
Task.Delay(TimeSpan.FromSeconds(2))));
|
||||
await bothReceives;
|
||||
|
||||
Assert.Equal(new byte[] { 1, 2 }, connection.Received.ToArray());
|
||||
Assert.Equal(0u, replacementBuffer.Available);
|
||||
}
|
||||
finally
|
||||
{
|
||||
releaseParser.Set();
|
||||
await firstReceive;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NetworkConnection_IgnoresCallbacksFromUnassignedSocket()
|
||||
{
|
||||
var connection = new RecordingConnection();
|
||||
var staleSocket = new TestSocket();
|
||||
var currentSocket = new TestSocket();
|
||||
var connectEvents = 0;
|
||||
var closeEvents = 0;
|
||||
connection.OnConnect += _ => connectEvents++;
|
||||
connection.OnClose += _ => closeEvents++;
|
||||
|
||||
connection.Assign(staleSocket);
|
||||
Assert.Same(staleSocket, connection.Unassign());
|
||||
connection.Assign(currentSocket);
|
||||
|
||||
var staleBuffer = BufferWith(9);
|
||||
connection.NetworkReceive(staleSocket, staleBuffer);
|
||||
connection.NetworkConnect(staleSocket);
|
||||
connection.NetworkClose(staleSocket);
|
||||
|
||||
Assert.Equal(1u, staleBuffer.Available);
|
||||
Assert.Empty(connection.Received);
|
||||
Assert.Equal(0, connection.ConnectedCalls);
|
||||
Assert.Equal(0, connection.DisconnectedCalls);
|
||||
Assert.Equal(0, connectEvents);
|
||||
Assert.Equal(0, closeEvents);
|
||||
|
||||
connection.NetworkConnect(currentSocket);
|
||||
connection.NetworkReceive(currentSocket, BufferWith(1));
|
||||
connection.NetworkClose(currentSocket);
|
||||
|
||||
Assert.Equal(new byte[] { 1 }, connection.Received.ToArray());
|
||||
Assert.Equal(1, connection.ConnectedCalls);
|
||||
Assert.Equal(1, connection.DisconnectedCalls);
|
||||
Assert.Equal(1, connectEvents);
|
||||
Assert.Equal(1, closeEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NetworkServer_ClosesSocketAcceptedAfterStopSnapshot()
|
||||
{
|
||||
var acceptedSocket = new TestSocket();
|
||||
using var listener = new BlockingListener(acceptedSocket);
|
||||
var server = new TestServer();
|
||||
|
||||
try
|
||||
{
|
||||
server.Start(listener);
|
||||
Assert.True(listener.AcceptEntered.Wait(TimeSpan.FromSeconds(2)));
|
||||
|
||||
server.Stop();
|
||||
listener.ReleaseAccept.Set();
|
||||
|
||||
Assert.True(SpinWait.SpinUntil(
|
||||
() => acceptedSocket.State == SocketState.Closed,
|
||||
TimeSpan.FromSeconds(2)));
|
||||
Assert.Empty(server.Connections);
|
||||
Assert.Equal(0, Volatile.Read(ref acceptedSocket.BeginCalls));
|
||||
Assert.Equal(0, Volatile.Read(ref server.ClientConnectedCalls));
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.ReleaseAccept.Set();
|
||||
server.Destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private static NetworkBuffer BufferWith(byte value)
|
||||
{
|
||||
var buffer = new NetworkBuffer();
|
||||
buffer.Write(new[] { value });
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private class RecordingConnection : NetworkConnection
|
||||
{
|
||||
public ConcurrentQueue<byte> Received { get; } = new();
|
||||
public int ConnectedCalls;
|
||||
public int DisconnectedCalls;
|
||||
|
||||
protected override void DataReceived(NetworkBuffer buffer)
|
||||
{
|
||||
var data = buffer.Read();
|
||||
if (data == null)
|
||||
return;
|
||||
|
||||
foreach (var value in data)
|
||||
Received.Enqueue(value);
|
||||
}
|
||||
|
||||
protected override void Connected()
|
||||
=> Interlocked.Increment(ref ConnectedCalls);
|
||||
|
||||
protected override void Disconnected()
|
||||
=> Interlocked.Increment(ref DisconnectedCalls);
|
||||
}
|
||||
|
||||
private sealed class BlockingConnection : RecordingConnection
|
||||
{
|
||||
private readonly ManualResetEventSlim parserEntered;
|
||||
private readonly ManualResetEventSlim releaseParser;
|
||||
private int blocked;
|
||||
|
||||
public BlockingConnection(
|
||||
ManualResetEventSlim parserEntered,
|
||||
ManualResetEventSlim releaseParser)
|
||||
{
|
||||
this.parserEntered = parserEntered;
|
||||
this.releaseParser = releaseParser;
|
||||
}
|
||||
|
||||
protected override void DataReceived(NetworkBuffer buffer)
|
||||
{
|
||||
base.DataReceived(buffer);
|
||||
|
||||
if (Interlocked.CompareExchange(ref blocked, 1, 0) == 0)
|
||||
{
|
||||
parserEntered.Set();
|
||||
releaseParser.Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ServerConnection : NetworkConnection
|
||||
{
|
||||
protected override void DataReceived(NetworkBuffer buffer) { }
|
||||
protected override void Connected() { }
|
||||
protected override void Disconnected() { }
|
||||
}
|
||||
|
||||
private sealed class TestServer : NetworkServer<ServerConnection>
|
||||
{
|
||||
public int ClientConnectedCalls;
|
||||
|
||||
protected override void ClientConnected(ServerConnection connection)
|
||||
=> Interlocked.Increment(ref ClientConnectedCalls);
|
||||
|
||||
protected override void ClientDisconnected(ServerConnection connection) { }
|
||||
}
|
||||
|
||||
private class TestSocket : ISocket
|
||||
{
|
||||
public event DestroyedEvent? OnDestroy;
|
||||
|
||||
public SocketState State { get; protected set; } = SocketState.Established;
|
||||
public INetworkReceiver<ISocket> Receiver { get; set; } = null!;
|
||||
public IPEndPoint RemoteEndPoint { get; } = new(IPAddress.Loopback, 12345);
|
||||
public IPEndPoint LocalEndPoint { get; } = new(IPAddress.Loopback, 54321);
|
||||
public int BeginCalls;
|
||||
|
||||
public void Close()
|
||||
{
|
||||
if (State == SocketState.Closed)
|
||||
return;
|
||||
|
||||
State = SocketState.Closed;
|
||||
Receiver?.NetworkClose(this);
|
||||
}
|
||||
|
||||
public void Destroy()
|
||||
{
|
||||
Close();
|
||||
OnDestroy?.Invoke(this);
|
||||
OnDestroy = null;
|
||||
}
|
||||
|
||||
public bool Begin()
|
||||
{
|
||||
Interlocked.Increment(ref BeginCalls);
|
||||
return State == SocketState.Established;
|
||||
}
|
||||
|
||||
public AsyncReply<bool> BeginAsync() => new(Begin());
|
||||
public AsyncReply<bool> Connect(string hostname, ushort port) => new(true);
|
||||
public virtual ISocket Accept() => null!;
|
||||
public AsyncReply<ISocket> AcceptAsync() => new((ISocket)null!);
|
||||
public void Send(byte[] message) { }
|
||||
public void Send(byte[] message, int offset, int length) { }
|
||||
public AsyncReply<bool> SendAsync(byte[] message, int offset, int length) => new(true);
|
||||
public void Hold() { }
|
||||
public void Unhold() { }
|
||||
}
|
||||
|
||||
private sealed class BlockingListener : TestSocket, IDisposable
|
||||
{
|
||||
private readonly ISocket acceptedSocket;
|
||||
private int acceptCalls;
|
||||
|
||||
public BlockingListener(ISocket acceptedSocket)
|
||||
{
|
||||
this.acceptedSocket = acceptedSocket;
|
||||
State = SocketState.Listening;
|
||||
}
|
||||
|
||||
public ManualResetEventSlim AcceptEntered { get; } = new();
|
||||
public ManualResetEventSlim ReleaseAccept { get; } = new();
|
||||
|
||||
public override ISocket Accept()
|
||||
{
|
||||
if (Interlocked.Increment(ref acceptCalls) != 1)
|
||||
return null!;
|
||||
|
||||
AcceptEntered.Set();
|
||||
ReleaseAccept.Wait(TimeSpan.FromSeconds(5));
|
||||
return acceptedSocket;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ReleaseAccept.Set();
|
||||
AcceptEntered.Dispose();
|
||||
ReleaseAccept.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Esiur.Data;
|
||||
using Esiur.Net.Packets;
|
||||
using Esiur.Net.Packets.Http;
|
||||
using Esiur.Net.Packets.WebSocket;
|
||||
using Esiur.Resource;
|
||||
using System.Text;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public class PacketCodecTests
|
||||
{
|
||||
[Fact]
|
||||
public void EpParser_ValidatesSliceBounds()
|
||||
{
|
||||
var packet = new EpAuthPacket(new Warehouse());
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => packet.Parse(Array.Empty<byte>(), 1, 0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => packet.Parse(Array.Empty<byte>(), 0, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebSocket_ComposesAndParsesUnmaskedFrame()
|
||||
{
|
||||
var packet = new WebsocketPacket
|
||||
{
|
||||
FIN = true,
|
||||
Opcode = WebsocketPacket.WSOpcode.BinaryFrame,
|
||||
Message = new byte[] { 1, 2, 3 }
|
||||
};
|
||||
|
||||
Assert.True(packet.Compose());
|
||||
Assert.Equal(new byte[] { 0x82, 0x03, 1, 2, 3 }, packet.Data);
|
||||
|
||||
var parsed = new WebsocketPacket();
|
||||
Assert.Equal(packet.Data.Length, parsed.Parse(packet.Data, 0, (uint)packet.Data.Length));
|
||||
Assert.Equal(packet.Message, parsed.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebSocket_MasksOutboundPayloadAndRestoresItWhenParsed()
|
||||
{
|
||||
var message = Encoding.UTF8.GetBytes("hello");
|
||||
var packet = new WebsocketPacket
|
||||
{
|
||||
FIN = true,
|
||||
Mask = true,
|
||||
MaskKey = new byte[] { 1, 2, 3, 4 },
|
||||
Opcode = WebsocketPacket.WSOpcode.TextFrame,
|
||||
Message = message
|
||||
};
|
||||
|
||||
packet.Compose();
|
||||
|
||||
Assert.Equal(0x81, packet.Data[0]);
|
||||
Assert.Equal(0x85, packet.Data[1]);
|
||||
Assert.Equal(packet.MaskKey, packet.Data.Skip(2).Take(4));
|
||||
Assert.NotEqual(message, packet.Data.Skip(6).ToArray());
|
||||
|
||||
var parsed = new WebsocketPacket();
|
||||
Assert.Equal(packet.Data.Length, parsed.Parse(packet.Data, 0, (uint)packet.Data.Length));
|
||||
Assert.Equal(message, parsed.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebSocket_RejectsOversizedDeclarationBeforePayloadArrives()
|
||||
{
|
||||
var packet = new WebsocketPacket { MaximumPayloadLength = 1_024 };
|
||||
var header = new byte[] { 0x82, 126, 0x04, 0x01 };
|
||||
|
||||
Assert.Throws<ParserLimitException>(() => packet.Parse(header, 0, (uint)header.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HttpRequest_ParsesOffsetBodyQueryCookiesAndForm()
|
||||
{
|
||||
const string request =
|
||||
"POST /submit?a=1 HTTP/1.1\r\n" +
|
||||
"Host: example.test\r\n" +
|
||||
"Cookie: sid=abc; flag\r\n" +
|
||||
"Content-Type: application/x-www-form-urlencoded\r\n" +
|
||||
"Content-Length: 7\r\n\r\n" +
|
||||
"x=hello";
|
||||
var packetBytes = Encoding.ASCII.GetBytes(request);
|
||||
var data = new byte[packetBytes.Length + 2];
|
||||
Buffer.BlockCopy(packetBytes, 0, data, 2, packetBytes.Length);
|
||||
|
||||
var packet = new HttpRequestPacket();
|
||||
var consumed = packet.Parse(data, 2, (uint)data.Length);
|
||||
|
||||
Assert.Equal(packetBytes.Length, consumed);
|
||||
Assert.Equal(Esiur.Net.Packets.Http.HttpMethod.POST, packet.Method);
|
||||
Assert.Equal("/submit", packet.Filename);
|
||||
Assert.Equal("1", packet.Query["A"]);
|
||||
Assert.Equal("abc", packet.Cookies["SID"]);
|
||||
Assert.Equal(string.Empty, packet.Cookies["flag"]);
|
||||
Assert.Equal("hello", packet.PostForms["x"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HttpRequest_RejectsOversizedContentBeforeBuffering()
|
||||
{
|
||||
var data = Encoding.ASCII.GetBytes(
|
||||
"POST / HTTP/1.1\r\nContent-Length: 5\r\n\r\n");
|
||||
var packet = new HttpRequestPacket { MaximumContentLength = 4 };
|
||||
|
||||
Assert.Throws<ParserLimitException>(() => packet.Parse(data, 0, (uint)data.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HttpResponse_ParsesBodyFromHeaderBoundaryAndUsesNegativeMissingCount()
|
||||
{
|
||||
var complete = Encoding.ASCII.GetBytes(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nSet-Cookie: sid=abc; Path=/; HttpOnly\r\n\r\nhello");
|
||||
var prefixed = new byte[complete.Length + 2];
|
||||
Buffer.BlockCopy(complete, 0, prefixed, 2, complete.Length);
|
||||
|
||||
var packet = new HttpResponsePacket();
|
||||
Assert.Equal(complete.Length, packet.Parse(prefixed, 2, (uint)prefixed.Length));
|
||||
Assert.Equal("hello", Encoding.ASCII.GetString(packet.Message));
|
||||
Assert.Single(packet.Cookies);
|
||||
Assert.True(packet.Cookies[0].HttpOnly);
|
||||
|
||||
var incomplete = Encoding.ASCII.GetBytes(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhe");
|
||||
Assert.Equal(-3, new HttpResponsePacket().Parse(incomplete, 0, (uint)incomplete.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HttpResponse_ComposeCalculatesLengthAndAppendsBody()
|
||||
{
|
||||
var packet = new HttpResponsePacket
|
||||
{
|
||||
Text = "OK",
|
||||
Message = Encoding.ASCII.GetBytes("hello")
|
||||
};
|
||||
|
||||
packet.Compose(HttpComposeOption.AllCalculateLength);
|
||||
var response = Encoding.ASCII.GetString(packet.Data);
|
||||
|
||||
Assert.Contains("Content-Length: 5\r\n", response, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.EndsWith("\r\n\r\nhello", response, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringKeyList_LookupsRemainCaseInsensitiveWithoutDuplicates()
|
||||
{
|
||||
var values = new StringKeyList();
|
||||
values.Add("Content-Type", "text/plain");
|
||||
values["CONTENT-TYPE"] = "application/json";
|
||||
|
||||
Assert.Equal(1, values.Count);
|
||||
Assert.Equal("application/json", values["content-type"]);
|
||||
Assert.True(values.ContainsKey("Content-Type"));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Esiur.Data;
|
||||
using Esiur.Net.Packets;
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Resource;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
@@ -52,4 +53,74 @@ public class ParserSecurityTests
|
||||
|
||||
Assert.Throws<ParserLimitException>(() => Codec.ParseSync(data, 0, warehouse));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TruMetadataParser_EnforcesConfiguredDepthInSyncParsing()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
warehouse.Configuration.Parser.MaximumTypeMetadataDepth = 3;
|
||||
|
||||
var accepted = ParsedTdu.ParseSync(
|
||||
ComposeTypedTdu(ComposeNestedTypeMetadata(3)),
|
||||
0,
|
||||
5,
|
||||
warehouse);
|
||||
|
||||
Assert.Equal(TduClass.Typed, accepted.Class);
|
||||
Assert.Throws<ParserLimitException>(() => ParsedTdu.ParseSync(
|
||||
ComposeTypedTdu(ComposeNestedTypeMetadata(4)),
|
||||
0,
|
||||
6,
|
||||
warehouse));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TruMetadataParser_EnforcesConnectionWarehouseDepthInAsyncParsing()
|
||||
{
|
||||
var warehouse = new Warehouse();
|
||||
warehouse.Configuration.Parser.MaximumTypeMetadataDepth = 2;
|
||||
var connection = new EpConnection();
|
||||
await connection.Handle(
|
||||
ResourceOperation.Configure,
|
||||
new EpServerConnectionContext
|
||||
{
|
||||
Server = new EpServer(),
|
||||
Warehouse = warehouse,
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var data = ComposeTypedTdu(ComposeNestedTypeMetadata(3));
|
||||
|
||||
await Assert.ThrowsAsync<ParserLimitException>(async () =>
|
||||
await ParsedTdu.ParseAsync(data, connection));
|
||||
}
|
||||
finally
|
||||
{
|
||||
connection.Destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ComposeNestedTypeMetadata(int depth)
|
||||
{
|
||||
if (depth < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(depth));
|
||||
|
||||
var metadata = new byte[depth];
|
||||
Array.Fill(metadata, (byte)TruIdentifier.TypedList, 0, depth - 1);
|
||||
metadata[^1] = (byte)TruIdentifier.Bool;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static byte[] ComposeTypedTdu(byte[] metadata)
|
||||
{
|
||||
if (metadata.Length > byte.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(metadata));
|
||||
|
||||
var data = new byte[metadata.Length + 2];
|
||||
data[0] = 0x88; // Typed TDU with a one-byte payload-length prefix.
|
||||
data[1] = (byte)metadata.Length;
|
||||
Buffer.BlockCopy(metadata, 0, data, 2, metadata.Length);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Esiur.Core;
|
||||
using Esiur.Data;
|
||||
using Esiur.Net;
|
||||
using Esiur.Net.Packets.WebSocket;
|
||||
using Esiur.Net.Sockets;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public class SocketProtocolSecurityTests
|
||||
{
|
||||
[Fact]
|
||||
public void NetworkServer_DoesNotBlockAcceptLoopOnSocketInitialization()
|
||||
{
|
||||
var neverCompletes = new AsyncReply<bool>();
|
||||
var stalled = new TestSocket(neverCompletes);
|
||||
var ready = new TestSocket(new AsyncReply<bool>(true));
|
||||
var listener = new TestListener(stalled, ready);
|
||||
var server = new TestServer
|
||||
{
|
||||
ConnectionInitializationTimeout = TimeSpan.FromMilliseconds(100)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
server.Start(listener);
|
||||
|
||||
Assert.True(SpinWait.SpinUntil(
|
||||
() => Volatile.Read(ref ready.BeginCalls) == 1,
|
||||
TimeSpan.FromSeconds(2)));
|
||||
Assert.True(SpinWait.SpinUntil(
|
||||
() => stalled.State == SocketState.Closed,
|
||||
TimeSpan.FromSeconds(2)));
|
||||
Assert.Equal(SocketState.Established, ready.State);
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.Destroy();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebSocketServer_RejectsUnmaskedClientFrames()
|
||||
{
|
||||
var transport = new TestSocket();
|
||||
var socket = new WSocket(transport, isServer: true)
|
||||
{
|
||||
Receiver = new TestReceiver()
|
||||
};
|
||||
var frame = ComposeFrame(
|
||||
WebsocketPacket.WSOpcode.BinaryFrame,
|
||||
final: true,
|
||||
masked: false,
|
||||
new byte[] { 1, 2, 3 });
|
||||
|
||||
var input = new NetworkBuffer();
|
||||
input.Write(frame);
|
||||
socket.NetworkReceive(transport, input);
|
||||
|
||||
Assert.Equal(SocketState.Closed, transport.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebSocket_ReassemblesFragmentsBeforeDeliveringThem()
|
||||
{
|
||||
var transport = new TestSocket();
|
||||
var receiver = new TestReceiver();
|
||||
var socket = new WSocket(transport, isServer: true)
|
||||
{
|
||||
Receiver = receiver
|
||||
};
|
||||
var first = ComposeFrame(
|
||||
WebsocketPacket.WSOpcode.BinaryFrame,
|
||||
final: false,
|
||||
masked: true,
|
||||
new byte[] { 1, 2 });
|
||||
var second = ComposeFrame(
|
||||
WebsocketPacket.WSOpcode.ContinuationFrame,
|
||||
final: true,
|
||||
masked: true,
|
||||
new byte[] { 3, 4 });
|
||||
|
||||
var input = new NetworkBuffer();
|
||||
input.Write(first.Concat(second).ToArray());
|
||||
socket.NetworkReceive(transport, input);
|
||||
|
||||
Assert.Equal(SocketState.Established, transport.State);
|
||||
Assert.Single(receiver.Messages);
|
||||
Assert.Equal(new byte[] { 1, 2, 3, 4 }, receiver.Messages[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebSocket_RejectsInvalidUtf8AcrossFragments()
|
||||
{
|
||||
var transport = new TestSocket();
|
||||
var receiver = new TestReceiver();
|
||||
var socket = new WSocket(transport, isServer: true)
|
||||
{
|
||||
Receiver = receiver
|
||||
};
|
||||
var first = ComposeFrame(
|
||||
WebsocketPacket.WSOpcode.TextFrame,
|
||||
final: false,
|
||||
masked: true,
|
||||
new byte[] { 0xC3 });
|
||||
var second = ComposeFrame(
|
||||
WebsocketPacket.WSOpcode.ContinuationFrame,
|
||||
final: true,
|
||||
masked: true,
|
||||
new byte[] { 0x28 });
|
||||
|
||||
var input = new NetworkBuffer();
|
||||
input.Write(first.Concat(second).ToArray());
|
||||
socket.NetworkReceive(transport, input);
|
||||
|
||||
Assert.Equal(SocketState.Closed, transport.State);
|
||||
Assert.Empty(receiver.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebSocketClient_MasksEveryOutgoingFrameWithAFreshKey()
|
||||
{
|
||||
var transport = new TestSocket();
|
||||
var socket = new WSocket(transport, isServer: false);
|
||||
|
||||
socket.Send(new byte[] { 5, 6, 7 });
|
||||
socket.Send(new byte[] { 8, 9 });
|
||||
|
||||
Assert.Equal(2, transport.Sent.Count);
|
||||
var frame = transport.Sent[0];
|
||||
var secondFrame = transport.Sent[1];
|
||||
Assert.False(
|
||||
frame.Skip(2).Take(4)
|
||||
.SequenceEqual(secondFrame.Skip(2).Take(4)),
|
||||
"Each client frame must use a fresh masking key.");
|
||||
|
||||
var parsed = new WebsocketPacket { ExpectedMask = true };
|
||||
Assert.Equal(frame.Length, parsed.Parse(frame, 0, (uint)frame.Length));
|
||||
Assert.Equal(new byte[] { 5, 6, 7 }, parsed.Message);
|
||||
|
||||
parsed = new WebsocketPacket { ExpectedMask = true };
|
||||
Assert.Equal(
|
||||
secondFrame.Length,
|
||||
parsed.Parse(secondFrame, 0, (uint)secondFrame.Length));
|
||||
Assert.Equal(new byte[] { 8, 9 }, parsed.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebSocket_MessageLimitAlsoAppliesToUnfragmentedFrames()
|
||||
{
|
||||
var transport = new TestSocket();
|
||||
var socket = new WSocket(transport, isServer: true)
|
||||
{
|
||||
Receiver = new TestReceiver(),
|
||||
MaximumMessageLength = 2
|
||||
};
|
||||
var frame = ComposeFrame(
|
||||
WebsocketPacket.WSOpcode.BinaryFrame,
|
||||
final: true,
|
||||
masked: true,
|
||||
new byte[] { 1, 2, 3 });
|
||||
|
||||
var input = new NetworkBuffer();
|
||||
input.Write(frame);
|
||||
socket.NetworkReceive(transport, input);
|
||||
|
||||
Assert.Equal(SocketState.Closed, transport.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebSocket_DestroyIsReentrantAndForwardsTheWrapperOnClose()
|
||||
{
|
||||
var transport = new TestSocket();
|
||||
var receiver = new ReentrantCloseReceiver();
|
||||
var socket = new WSocket(transport, isServer: true)
|
||||
{
|
||||
Receiver = receiver
|
||||
};
|
||||
receiver.OnClose = socket.Destroy;
|
||||
|
||||
socket.Destroy();
|
||||
socket.Destroy();
|
||||
|
||||
Assert.Equal(SocketState.Closed, transport.State);
|
||||
Assert.Same(socket, receiver.ClosedSender);
|
||||
Assert.Null(transport.Receiver);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TcpSocket_BoundsCopiedPendingSendData()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
|
||||
using var client = new Socket(
|
||||
AddressFamily.InterNetwork,
|
||||
SocketType.Stream,
|
||||
ProtocolType.Tcp);
|
||||
var endpoint = (IPEndPoint)listener.LocalEndpoint;
|
||||
var connectTask = client.ConnectAsync(endpoint);
|
||||
var accepted = await listener.AcceptSocketAsync();
|
||||
await connectTask;
|
||||
|
||||
var socket = new TcpSocket(accepted)
|
||||
{
|
||||
MaximumPendingSendBytes = 4
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
socket.Hold();
|
||||
socket.Send(new byte[] { 1, 2, 3, 4 });
|
||||
|
||||
Assert.Equal(4, socket.PendingSendBytes);
|
||||
Assert.Throws<InvalidOperationException>(() => socket.Send(new byte[] { 5 }));
|
||||
|
||||
var rejected = socket.SendAsync(new byte[] { 6 }, 0, 1);
|
||||
var exception = await Assert.ThrowsAsync<AsyncException>(async () => await rejected);
|
||||
Assert.IsType<InvalidOperationException>(exception.InnerException);
|
||||
Assert.Equal(4, socket.PendingSendBytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
socket.Destroy();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TcpSocket_SendCallbackFailureDoesNotCloseTheTransport()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
|
||||
using var client = new Socket(
|
||||
AddressFamily.InterNetwork,
|
||||
SocketType.Stream,
|
||||
ProtocolType.Tcp);
|
||||
var endpoint = (IPEndPoint)listener.LocalEndpoint;
|
||||
var connectTask = client.ConnectAsync(endpoint);
|
||||
using var accepted = await listener.AcceptSocketAsync();
|
||||
await connectTask;
|
||||
|
||||
var socket = new TcpSocket(accepted);
|
||||
|
||||
try
|
||||
{
|
||||
socket.Hold();
|
||||
var reply = socket.SendAsync(new byte[] { 42 }, 0, 1);
|
||||
_ = reply.Then(_ => throw new InvalidOperationException("Consumer callback failure."));
|
||||
|
||||
socket.Unhold();
|
||||
|
||||
Assert.True(await reply);
|
||||
Assert.Equal(SocketState.Established, socket.State);
|
||||
Assert.True(SpinWait.SpinUntil(
|
||||
() => socket.PendingSendBytes == 0,
|
||||
TimeSpan.FromSeconds(2)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
socket.Destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ComposeFrame(
|
||||
WebsocketPacket.WSOpcode opcode,
|
||||
bool final,
|
||||
bool masked,
|
||||
byte[] payload)
|
||||
{
|
||||
var packet = new WebsocketPacket
|
||||
{
|
||||
FIN = final,
|
||||
Opcode = opcode,
|
||||
Mask = masked,
|
||||
MaskKey = new byte[] { 1, 2, 3, 4 },
|
||||
Message = payload
|
||||
};
|
||||
packet.Compose();
|
||||
return packet.Data;
|
||||
}
|
||||
|
||||
private sealed class TestReceiver : INetworkReceiver<ISocket>
|
||||
{
|
||||
public List<byte[]> Messages { get; } = new();
|
||||
|
||||
public void NetworkReceive(ISocket sender, NetworkBuffer buffer)
|
||||
{
|
||||
var message = buffer.Read();
|
||||
if (message != null)
|
||||
Messages.Add(message);
|
||||
}
|
||||
|
||||
public void NetworkConnect(ISocket sender) { }
|
||||
public void NetworkClose(ISocket sender) { }
|
||||
}
|
||||
|
||||
private sealed class ReentrantCloseReceiver : INetworkReceiver<ISocket>
|
||||
{
|
||||
public Action? OnClose { get; set; }
|
||||
public ISocket? ClosedSender { get; private set; }
|
||||
|
||||
public void NetworkReceive(ISocket sender, NetworkBuffer buffer) { }
|
||||
public void NetworkConnect(ISocket sender) { }
|
||||
|
||||
public void NetworkClose(ISocket sender)
|
||||
{
|
||||
ClosedSender = sender;
|
||||
OnClose?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSocket : ISocket
|
||||
{
|
||||
private readonly AsyncReply<bool>? beginReply;
|
||||
|
||||
public event DestroyedEvent? OnDestroy;
|
||||
|
||||
public SocketState State { get; private set; } = SocketState.Established;
|
||||
public INetworkReceiver<ISocket> Receiver { get; set; } = null!;
|
||||
public IPEndPoint RemoteEndPoint { get; } = new(IPAddress.Loopback, 12345);
|
||||
public IPEndPoint LocalEndPoint { get; } = new(IPAddress.Loopback, 54321);
|
||||
public List<byte[]> Sent { get; } = new();
|
||||
public int BeginCalls;
|
||||
|
||||
public TestSocket(AsyncReply<bool>? beginReply = null)
|
||||
=> this.beginReply = beginReply;
|
||||
|
||||
public void Send(byte[] message) => Send(message, 0, message.Length);
|
||||
|
||||
public void Send(byte[] message, int offset, int length)
|
||||
{
|
||||
var copy = new byte[length];
|
||||
Buffer.BlockCopy(message, offset, copy, 0, length);
|
||||
Sent.Add(copy);
|
||||
}
|
||||
|
||||
public AsyncReply<bool> SendAsync(byte[] message, int offset, int length)
|
||||
{
|
||||
Send(message, offset, length);
|
||||
return new AsyncReply<bool>(true);
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
if (State == SocketState.Closed)
|
||||
return;
|
||||
|
||||
State = SocketState.Closed;
|
||||
Receiver?.NetworkClose(this);
|
||||
}
|
||||
|
||||
public AsyncReply<bool> Connect(string hostname, ushort port) => new(true);
|
||||
public bool Begin() => true;
|
||||
public AsyncReply<bool> BeginAsync()
|
||||
{
|
||||
Interlocked.Increment(ref BeginCalls);
|
||||
return beginReply ?? new AsyncReply<bool>(true);
|
||||
}
|
||||
public AsyncReply<ISocket> AcceptAsync() => new((ISocket)null!);
|
||||
public ISocket Accept() => null!;
|
||||
public void Hold() { }
|
||||
public void Unhold() { }
|
||||
|
||||
public void Destroy()
|
||||
{
|
||||
Close();
|
||||
OnDestroy?.Invoke(this);
|
||||
OnDestroy = null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestListener : ISocket
|
||||
{
|
||||
private readonly Queue<ISocket> accepted;
|
||||
|
||||
public TestListener(params ISocket[] accepted)
|
||||
=> this.accepted = new Queue<ISocket>(accepted);
|
||||
|
||||
public event DestroyedEvent? OnDestroy;
|
||||
public SocketState State { get; private set; } = SocketState.Listening;
|
||||
public INetworkReceiver<ISocket> Receiver { get; set; } = null!;
|
||||
public IPEndPoint RemoteEndPoint => null!;
|
||||
public IPEndPoint LocalEndPoint { get; } = new(IPAddress.Loopback, 10518);
|
||||
|
||||
public ISocket Accept()
|
||||
{
|
||||
lock (accepted)
|
||||
return accepted.Count == 0 ? null! : accepted.Dequeue();
|
||||
}
|
||||
|
||||
public void Close() => State = SocketState.Closed;
|
||||
public void Destroy()
|
||||
{
|
||||
Close();
|
||||
OnDestroy?.Invoke(this);
|
||||
}
|
||||
|
||||
public AsyncReply<bool> SendAsync(byte[] message, int offset, int length) => new(false);
|
||||
public void Send(byte[] message) { }
|
||||
public void Send(byte[] message, int offset, int length) { }
|
||||
public AsyncReply<bool> Connect(string hostname, ushort port) => new(false);
|
||||
public bool Begin() => false;
|
||||
public AsyncReply<bool> BeginAsync() => new(false);
|
||||
public AsyncReply<ISocket> AcceptAsync() => new(Accept());
|
||||
public void Hold() { }
|
||||
public void Unhold() { }
|
||||
}
|
||||
|
||||
private sealed class TestConnection : NetworkConnection
|
||||
{
|
||||
protected override void DataReceived(NetworkBuffer buffer) { }
|
||||
protected override void Connected() { }
|
||||
protected override void Disconnected() { }
|
||||
}
|
||||
|
||||
private sealed class TestServer : NetworkServer<TestConnection>
|
||||
{
|
||||
protected override void ClientDisconnected(TestConnection connection) { }
|
||||
protected override void ClientConnected(TestConnection connection) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Esiur.Proxy;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public class TypeDefGeneratorSafetyTests
|
||||
{
|
||||
[Fact]
|
||||
public void GeneratedFileName_RejectsPathsAndAcceptsQualifiedTypeNames()
|
||||
{
|
||||
Assert.Equal(
|
||||
"Example.Contracts.Device.g.cs",
|
||||
TypeDefGenerator.GetGeneratedFileName("Example.Contracts.Device"));
|
||||
|
||||
Assert.Throws<InvalidDataException>(
|
||||
() => TypeDefGenerator.GetGeneratedFileName("../outside"));
|
||||
Assert.Throws<InvalidDataException>(
|
||||
() => TypeDefGenerator.GetGeneratedFileName("folder/type"));
|
||||
Assert.Throws<InvalidDataException>(
|
||||
() => TypeDefGenerator.GetGeneratedFileName(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cleanup_DeletesOnlyManifestedGeneratedFiles()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"esiur-generator-{Guid.NewGuid():N}");
|
||||
var output = Path.Combine(root, "output");
|
||||
Directory.CreateDirectory(output);
|
||||
|
||||
try
|
||||
{
|
||||
var generated = Path.Combine(output, "Old.g.cs");
|
||||
var unlistedGenerated = Path.Combine(output, "Keep.g.cs");
|
||||
var userFile = Path.Combine(output, "notes.txt");
|
||||
var outsideFile = Path.Combine(root, "outside.g.cs");
|
||||
File.WriteAllText(generated, "generated");
|
||||
File.WriteAllText(unlistedGenerated, "unlisted");
|
||||
File.WriteAllText(userFile, "user");
|
||||
File.WriteAllText(outsideFile, "outside");
|
||||
File.WriteAllLines(
|
||||
Path.Combine(output, ".esiur-generated-files"),
|
||||
new[] { "Old.g.cs", "../outside.g.cs", "notes.txt" });
|
||||
|
||||
TypeDefGenerator.DeletePreviouslyGeneratedFiles(new DirectoryInfo(output));
|
||||
|
||||
Assert.False(File.Exists(generated));
|
||||
Assert.True(File.Exists(unlistedGenerated));
|
||||
Assert.True(File.Exists(userFile));
|
||||
Assert.True(File.Exists(outsideFile));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Esiur.Data;
|
||||
using Esiur.Data.Types;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Permissions;
|
||||
|
||||
namespace Esiur.Tests.Unit;
|
||||
|
||||
public class UserPermissionsManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExplicitMemberGrant_AllowsReadAndWriteActions()
|
||||
{
|
||||
var memberPermissions = new Map<string, object>
|
||||
{
|
||||
[ActionType.GetProperty.ToString()] = "yes",
|
||||
[ActionType.SetProperty.ToString()] = "yes"
|
||||
};
|
||||
var manager = CreateManager(new Map<string, object>
|
||||
{
|
||||
["Value"] = memberPermissions
|
||||
});
|
||||
var session = new Session { RemoteIdentity = "alice" };
|
||||
var member = new MemberDef { Name = "Value" };
|
||||
|
||||
Assert.Equal(
|
||||
Ruling.Allowed,
|
||||
manager.Applicable(null!, session, ActionType.GetProperty, member, null!));
|
||||
Assert.Equal(
|
||||
Ruling.Allowed,
|
||||
manager.Applicable(null!, session, ActionType.SetProperty, member, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingMemberOrAction_DeniesInsteadOfFallingThrough()
|
||||
{
|
||||
var manager = CreateManager(new Map<string, object>
|
||||
{
|
||||
["Value"] = new Map<string, object>
|
||||
{
|
||||
[ActionType.GetProperty.ToString()] = "yes"
|
||||
}
|
||||
});
|
||||
var session = new Session { RemoteIdentity = "alice" };
|
||||
|
||||
Assert.Equal(
|
||||
Ruling.Denied,
|
||||
manager.Applicable(
|
||||
null!,
|
||||
session,
|
||||
ActionType.Execute,
|
||||
new MemberDef { Name = "Missing" },
|
||||
null!));
|
||||
Assert.Equal(
|
||||
Ruling.Denied,
|
||||
manager.Applicable(
|
||||
null!,
|
||||
session,
|
||||
ActionType.SetProperty,
|
||||
new MemberDef { Name = "Value" },
|
||||
null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitResourceGrant_AllowsAttributeInquiry()
|
||||
{
|
||||
var manager = CreateManager(new Map<string, object>
|
||||
{
|
||||
["_get_attributes"] = "yes"
|
||||
});
|
||||
|
||||
Assert.Equal(
|
||||
Ruling.Allowed,
|
||||
manager.Applicable(
|
||||
null!,
|
||||
new Session { RemoteIdentity = "alice" },
|
||||
ActionType.InquireAttributes,
|
||||
null!,
|
||||
null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingIdentityAndMalformedPermissionMaps_FailClosed()
|
||||
{
|
||||
var manager = new UserPermissionsManager(new Map<string, object>
|
||||
{
|
||||
["alice"] = "not a permission map"
|
||||
});
|
||||
|
||||
Assert.Equal(
|
||||
Ruling.Denied,
|
||||
manager.Applicable(
|
||||
null!,
|
||||
new Session { RemoteIdentity = "bob" },
|
||||
ActionType.Attach,
|
||||
null!,
|
||||
null!));
|
||||
Assert.Equal(
|
||||
Ruling.Denied,
|
||||
manager.Applicable(
|
||||
null!,
|
||||
new Session { RemoteIdentity = "alice" },
|
||||
ActionType.Attach,
|
||||
null!,
|
||||
null!));
|
||||
}
|
||||
|
||||
private static UserPermissionsManager CreateManager(Map<string, object> permissions)
|
||||
=> new(new Map<string, object> { ["alice"] = permissions });
|
||||
}
|
||||
Reference in New Issue
Block a user