Files
wwtools/mbproxy/tests/Mbproxy.Tests/HostSmokeTests.cs
T
Joseph Doherty b222362ce0 mbproxy: remediate the 2026-05-16 code-review findings
Fixes every finding from the codereviews/2026-05-16 multi-agent review
(2 Critical, 20 Major, 38 Minor) and adds that review to the repo.

Highlights: dashboard XSS escape; response cache invalidated on the
write request (not just the response); ReloadValidator now runs at
startup so port collisions / duplicate names / malformed Resilience
profiles fail fast; AdminPort 0 genuinely disables the admin endpoint;
PlcListener accept-loop faults propagate to the supervisor's faulted
path; reconciler Restart builds before removing; Resilience pipelines
are restart-only from a frozen snapshot; multiplexer connect-race leak,
watchdog party-list snapshot, backend-response and FC16 framing
validation; frontend reconnect retry and util.js load guard; plus the
log-event/doc drift sweep and test-port hygiene.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:08:06 -04:00

144 lines
5.0 KiB
C#

using System.Collections.Concurrent;
using Mbproxy;
using Mbproxy.Options;
using Mbproxy.Proxy;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Hosting.Systemd;
using Microsoft.Extensions.Hosting.WindowsServices;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests;
/// <summary>
/// Smoke tests: host starts, logs <c>mbproxy.startup.ready</c>, and shuts down cleanly.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HostSmokeTests
{
[Fact]
public async Task HostSmoke_StartsAndStops_Cleanly_AndLogs_StartupReady()
{
// Arrange: build a host with an in-memory Serilog sink.
var sink = new CapturingSink();
var serilogLogger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Sink(sink)
.CreateLogger();
using var host = Host.CreateApplicationBuilder()
.ConfigureForTest(serilogLogger)
.Build();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
// Act
await host.StartAsync(cts.Token);
// Give ProxyWorker time to fire (it binds 0 listeners and logs startup.ready).
await Task.Delay(500, cts.Token);
await host.StopAsync(cts.Token);
// Assert: the startup.ready event was logged at Information.
var readyEvents = sink.Events
.Where(e =>
e.Level == LogEventLevel.Information &&
e.MessageTemplate.Text.Contains("mbproxy service ready"))
.ToList();
readyEvents.ShouldNotBeEmpty("ProxyWorker should have logged mbproxy.startup.ready");
}
[Fact]
public async Task HostSmoke_ShutdownIsOrdered()
{
// Arrange
using var host = Host.CreateApplicationBuilder()
.ConfigureForTest(new LoggerConfiguration().CreateLogger())
.Build();
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await host.StartAsync(startCts.Token);
// Act: stop must complete well within 2 s.
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var stopTask = host.StopAsync(stopCts.Token);
// Assert: does not throw / time out.
await stopTask.ShouldCompleteWithinAsync(TimeSpan.FromSeconds(3));
}
[Fact]
public async Task HostSmoke_BothInitSystemIntegrations_CoRegister_AndHostRunsCleanly()
{
// Arrange: register BOTH init-system integrations. Each is a no-op off its
// own init system, so on a test run (neither) the default console lifetime
// applies — they must co-register without conflict and leave the host
// startable and stoppable.
var builder = Host.CreateApplicationBuilder();
builder.Services.AddWindowsService();
builder.Services.AddSystemd();
builder.ConfigureForTest(new LoggerConfiguration().CreateLogger());
using var host = builder.Build();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
// Act + Assert: start/stop do not throw or time out.
await host.StartAsync(cts.Token);
await host.StopAsync(cts.Token);
}
}
/// <summary>
/// Helper to configure a <see cref="HostApplicationBuilder"/> for smoke tests,
/// wiring in an in-memory config and the workers under test.
/// </summary>
internal static class TestHostBuilderExtensions
{
public static HostApplicationBuilder ConfigureForTest(
this HostApplicationBuilder builder,
Serilog.ILogger serilogLogger)
{
// Minimal in-memory config so AddMbproxyOptions doesn't fail. AdminPort 0
// disables the admin endpoint — the smoke tests do not exercise it, and a fixed
// port would collide under parallel test execution.
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Mbproxy:AdminPort"] = "0",
});
builder.Services.AddSerilog(serilogLogger, dispose: false);
builder.AddMbproxyOptions();
// Register the no-op pipeline and ProxyWorker.
builder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
builder.Services.AddHostedService<ProxyWorker>();
return builder;
}
}
/// <summary>Serilog <see cref="ILogEventSink"/> that stores events for assertion.</summary>
internal sealed class CapturingSink : ILogEventSink
{
private readonly ConcurrentQueue<LogEvent> _events = new();
public IEnumerable<LogEvent> Events => _events;
public void Emit(LogEvent logEvent) => _events.Enqueue(logEvent);
}
internal static class TaskExtensions
{
public static async Task ShouldCompleteWithinAsync(this Task task, TimeSpan timeout)
{
var completed = await Task.WhenAny(task, Task.Delay(timeout));
completed.ShouldBe(task, $"Task did not complete within {timeout}");
await task; // propagate any exception
}
}