audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -0,0 +1,93 @@
using Microsoft.Extensions.Logging;
using Moq;
using StellaOps.Router.Common.Models;
using StellaOps.TestKit;
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Unit tests for <see cref="EndpointOverrideMerger"/>.
/// </summary>
public sealed class EndpointOverrideMergerTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Merge_InvalidTimeout_LogsWarningAndKeepsDefault()
{
var logger = new Mock<ILogger<EndpointOverrideMerger>>();
var merger = new EndpointOverrideMerger(logger.Object);
var endpoint = new EndpointDescriptor
{
ServiceName = "svc",
Version = "1.0.0",
Method = "GET",
Path = "/ping",
DefaultTimeout = TimeSpan.FromSeconds(10)
};
var config = new MicroserviceYamlConfig
{
Endpoints =
[
new EndpointOverrideConfig
{
Method = "GET",
Path = "/ping",
DefaultTimeout = "bogus"
}
]
};
var merged = merger.Merge([endpoint], config);
merged[0].DefaultTimeout.Should().Be(TimeSpan.FromSeconds(10));
VerifyWarning(logger, "Invalid defaultTimeout");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Merge_ValidTimeout_OverridesDefault()
{
var logger = new Mock<ILogger<EndpointOverrideMerger>>();
var merger = new EndpointOverrideMerger(logger.Object);
var endpoint = new EndpointDescriptor
{
ServiceName = "svc",
Version = "1.0.0",
Method = "GET",
Path = "/ping",
DefaultTimeout = TimeSpan.FromSeconds(10)
};
var config = new MicroserviceYamlConfig
{
Endpoints =
[
new EndpointOverrideConfig
{
Method = "GET",
Path = "/ping",
DefaultTimeout = "30s"
}
]
};
var merged = merger.Merge([endpoint], config);
merged[0].DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30));
}
private static void VerifyWarning(Mock<ILogger<EndpointOverrideMerger>> logger, string messageContains)
{
logger.Verify(
log => log.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((value, _) => value.ToString()!.Contains(messageContains, StringComparison.Ordinal)),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
}

View File

@@ -83,6 +83,34 @@ public sealed class HeaderCollectionTests
empty.Should().BeEmpty();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Empty_Add_Throws()
{
// Arrange
var empty = HeaderCollection.Empty;
// Act
var action = () => empty.Add("X-Test", "value");
// Assert
action.Should().Throw<InvalidOperationException>();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Empty_Set_Throws()
{
// Arrange
var empty = HeaderCollection.Empty;
// Act
var action = () => empty.Set("X-Test", "value");
// Assert
action.Should().Throw<InvalidOperationException>();
}
#endregion
#region Indexer Tests

View File

@@ -0,0 +1,147 @@
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Router.Common.Frames;
using StellaOps.Router.Common.Models;
using StellaOps.TestKit;
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Unit tests for <see cref="RequestDispatcher"/>.
/// </summary>
public sealed class RequestDispatcherTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DispatchAsync_MergesMultiValueHeaders()
{
var dispatcher = CreateDispatcher(typeof(MultiHeaderEndpoint));
var request = new RequestFrame
{
RequestId = "req-1",
Method = "GET",
Path = "/test",
Headers = new Dictionary<string, string>(),
Payload = ReadOnlyMemory<byte>.Empty
};
var response = await dispatcher.DispatchAsync(request, CancellationToken.None);
response.Headers.Should().ContainKey("X-Test");
response.Headers["X-Test"].Should().Be("one,two");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DispatchAsync_AllowsNonSeekableResponseBody()
{
var dispatcher = CreateDispatcher(typeof(NonSeekableEndpoint));
var request = new RequestFrame
{
RequestId = "req-2",
Method = "GET",
Path = "/test",
Headers = new Dictionary<string, string>(),
Payload = ReadOnlyMemory<byte>.Empty
};
var response = await dispatcher.DispatchAsync(request, CancellationToken.None);
Encoding.UTF8.GetString(response.Payload.ToArray()).Should().Be("ok");
}
private static RequestDispatcher CreateDispatcher(Type handlerType)
{
var registry = new EndpointRegistry();
registry.Register(new EndpointDescriptor
{
ServiceName = "svc",
Version = "1.0.0",
Method = "GET",
Path = "/test",
HandlerType = handlerType
});
var services = new ServiceCollection();
services.AddScoped(handlerType);
var provider = services.BuildServiceProvider();
return new RequestDispatcher(registry, provider, NullLogger<RequestDispatcher>.Instance);
}
private sealed class MultiHeaderEndpoint : IRawStellaEndpoint
{
public Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken)
{
var headers = new HeaderCollection();
headers.Add("X-Test", "one");
headers.Add("X-Test", "two");
return Task.FromResult(new RawResponse
{
StatusCode = 200,
Headers = headers,
Body = new MemoryStream(Encoding.UTF8.GetBytes("ok"))
});
}
}
private sealed class NonSeekableEndpoint : IRawStellaEndpoint
{
public Task<RawResponse> HandleAsync(RawRequestContext context, CancellationToken cancellationToken)
{
return Task.FromResult(new RawResponse
{
StatusCode = 200,
Headers = new HeaderCollection(),
Body = new NonSeekableReadStream(Encoding.UTF8.GetBytes("ok"))
});
}
}
private sealed class NonSeekableReadStream : Stream
{
private readonly MemoryStream _inner;
public NonSeekableReadStream(byte[] data)
{
_inner = new MemoryStream(data, writable: false);
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count)
{
return _inner.Read(buffer, offset, count);
}
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException();
}
public override void SetLength(long value)
{
throw new NotSupportedException();
}
public override void Write(byte[] buffer, int offset, int count)
{
throw new NotSupportedException();
}
}
}

View File

@@ -0,0 +1,65 @@
using System.Text;
using StellaOps.Microservice.Endpoints;
using StellaOps.Microservice.Validation;
using StellaOps.TestKit;
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Unit tests for schema discovery endpoints.
/// </summary>
public sealed class SchemaDiscoveryEndpointsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SchemaDetailEndpoint_UsesQueryParametersDirection()
{
var registry = new FakeSchemaRegistry();
var endpoint = new SchemaDetailEndpoint(registry);
var context = new RawRequestContext
{
PathParameters = new Dictionary<string, string>
{
["method"] = "get",
["path"] = "api/widgets"
},
QueryParameters = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["direction"] = "response"
},
Headers = new HeaderCollection()
};
var response = await endpoint.HandleAsync(context, CancellationToken.None);
response.StatusCode.Should().Be(200);
using var reader = new StreamReader(response.Body, Encoding.UTF8);
reader.ReadToEnd().Should().Be(FakeSchemaRegistry.ResponseSchema);
}
private sealed class FakeSchemaRegistry : ISchemaRegistry
{
public const string RequestSchema = "{\"type\":\"object\",\"title\":\"request\"}";
public const string ResponseSchema = "{\"type\":\"object\",\"title\":\"response\"}";
public Json.Schema.JsonSchema? GetRequestSchema(string method, string path) => null;
public Json.Schema.JsonSchema? GetResponseSchema(string method, string path) => null;
public string? GetSchemaText(string method, string path, SchemaDirection direction)
{
return direction == SchemaDirection.Response ? ResponseSchema : RequestSchema;
}
public string? GetSchemaETag(string method, string path, SchemaDirection direction) => null;
public bool HasSchema(string method, string path, SchemaDirection direction)
{
return method.Equals("GET", StringComparison.OrdinalIgnoreCase) &&
path.Equals("/api/widgets", StringComparison.Ordinal);
}
public IReadOnlyList<EndpointSchemaDefinition> GetAllSchemas() => [];
}
}

View File

@@ -0,0 +1,63 @@
using System.Threading.Channels;
using StellaOps.Microservice.Streaming;
using StellaOps.TestKit;
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Unit tests for streaming request/response streams.
/// </summary>
public sealed class StreamingStreamsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StreamingRequestBodyStream_ReadsChunksInOrder()
{
var channel = Channel.CreateUnbounded<StreamChunk>();
await channel.Writer.WriteAsync(new StreamChunk { Data = [1, 2], SequenceNumber = 0 });
await channel.Writer.WriteAsync(new StreamChunk { Data = [3, 4], SequenceNumber = 1, EndOfStream = true });
channel.Writer.Complete();
var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
var buffer = new byte[8];
var total = 0;
int read;
while ((read = await stream.ReadAsync(buffer.AsMemory(total), CancellationToken.None)) > 0)
{
total += read;
}
buffer.Take(total).Should().BeEquivalentTo(new byte[] { 1, 2, 3, 4 }, options => options.WithStrictOrdering());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StreamingResponseBodyStream_WritesChunksAndCompletes()
{
var channel = Channel.CreateUnbounded<StreamChunk>();
var stream = new StreamingResponseBodyStream(channel.Writer, chunkSize: 2, cancellationToken: CancellationToken.None);
await stream.WriteAsync(new byte[] { 1, 2, 3 }, 0, 3, CancellationToken.None);
await stream.CompleteAsync();
var chunks = new List<StreamChunk>();
while (await channel.Reader.WaitToReadAsync())
{
while (channel.Reader.TryRead(out var chunk))
{
chunks.Add(chunk);
if (chunk.EndOfStream)
{
break;
}
}
}
chunks.Should().NotBeEmpty();
chunks[^1].EndOfStream.Should().BeTrue();
var data = chunks.Where(c => c.Data.Length > 0).SelectMany(c => c.Data).ToArray();
data.Should().BeEquivalentTo(new byte[] { 1, 2, 3 }, options => options.WithStrictOrdering());
}
}

View File

@@ -0,0 +1,84 @@
using System.Text;
using StellaOps.TestKit;
namespace StellaOps.Microservice.Tests;
/// <summary>
/// Unit tests for <see cref="TypedEndpointAdapter"/>.
/// </summary>
public sealed class TypedEndpointAdapterTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Adapt_TypedEndpoint_AllowsNonSeekableBody()
{
var handler = TypedEndpointAdapter.Adapt<EchoRequest, EchoResponse>(new EchoEndpoint());
var payload = Encoding.UTF8.GetBytes("{\"name\":\"Ada\"}");
var context = new RawRequestContext
{
Body = new NonSeekableReadStream(payload)
};
var response = await handler(context, CancellationToken.None);
using var reader = new StreamReader(response.Body, Encoding.UTF8);
reader.ReadToEnd().Should().Contain("\"name\":\"Ada\"");
}
private sealed record EchoRequest(string Name);
private sealed record EchoResponse(string Name);
private sealed class EchoEndpoint : IStellaEndpoint<EchoRequest, EchoResponse>
{
public Task<EchoResponse> HandleAsync(EchoRequest request, CancellationToken cancellationToken)
{
return Task.FromResult(new EchoResponse(request.Name));
}
}
private sealed class NonSeekableReadStream : Stream
{
private readonly MemoryStream _inner;
public NonSeekableReadStream(byte[] data)
{
_inner = new MemoryStream(data, writable: false);
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count)
{
return _inner.Read(buffer, offset, count);
}
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException();
}
public override void SetLength(long value)
{
throw new NotSupportedException();
}
public override void Write(byte[] buffer, int offset, int count)
{
throw new NotSupportedException();
}
}
}