Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Darling/Darling.Tests/Darling.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

<ItemGroup>
<PackageReference Include="xunit.v3" />
<!-- #4128: a live HTTP proof of the web host's gates (Host-allowlist, network-mode token/CIDR) through
a real Kestrel TestServer pipeline, rather than the source-order pins above. -->
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
</ItemGroup>

<ItemGroup>
Expand Down
171 changes: 171 additions & 0 deletions Darling/Darling.Tests/DarlingWebHostGateLiveTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using PerformanceMonitor.Darling.Analysis;
using PerformanceMonitor.Darling.Service.Mcp;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// #4128 part (a): a live HTTP proof of the web host's gates, through the SAME <c>ConfigurePipeline</c>
/// method production calls (extracted from <c>TryStartServerAsync</c> for exactly this purpose), driven by a
/// real Kestrel pipeline over <see cref="TestServer"/> rather than by reading source text or calling the pure
/// decision functions directly. <see cref="HostHeaderGuardTests"/> already proves the WIRING ORDER by
/// parsing the source; this class proves the wired pipeline actually REFUSES and ADMITS requests the way the
/// source claims it does end to end (Host header spoof, missing/valid/wrong token, in/out-of-CIDR remote).
///
/// <para>The gates read <c>context.Connection.RemoteIpAddress</c>, so every request goes through
/// <see cref="TestServer.SendAsync"/> (which allows setting it) rather than through
/// <c>TestServer.CreateClient</c> (which does not expose it).</para>
/// </summary>
public sealed class DarlingWebHostGateLiveTests
{
private const string ListenIp = "192.168.1.205";
private const string AllowedCidr = "192.168.1.0/24";
private const string Token = "correct-token-value";
private static readonly IPAddress InCidrRemote = IPAddress.Parse("192.168.1.50");
private static readonly IPAddress OutOfCidrRemote = IPAddress.Parse("10.0.0.9");

/// <summary>
/// Builds a <see cref="TestServer"/> running the REAL <c>DarlingWebHostService.ConfigurePipeline</c> —
/// the exact method the production <c>TryStartServerAsync</c> calls right after <c>builder.Build()</c>,
/// in the same order, with the same values (see that call site). The only difference from production is
/// the transport (TestServer instead of Kestrel sockets) and the store pool (a data source that is never
/// opened, because none of these gates touch Postgres).
/// </summary>
private static async Task<TestServer> BuildServer(bool networkMode)
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Logging.ClearProviders();

// A pool the gates never open (they run entirely ahead of DarlingWebEndpoints.MapAll's routes).
var postgres = NpgsqlDataSource.Create("Host=localhost;Database=postgres;Username=darling");
builder.Services.AddSingleton(postgres);

var app = builder.Build();

var host = new DarlingWebHostService(
NullLogger<DarlingWebHostService>.Instance,
new WebRuntimeState(),
new CollectorRuntimeState(),
new WebTlsCertificateState(),
new BaselineCache());

host.ConfigurePipeline(
app,
postgres,
networkMode: networkMode,
networkListenIp: networkMode ? IPAddress.Parse(ListenIp) : null,
allowedCidr: IPNetwork.Parse(AllowedCidr),
accessToken: Token,
oidcClient: null);

await app.StartAsync();
return app.GetTestServer();
}

private static Task<HttpContext> Send(TestServer server, string path, string host, IPAddress? remote, string? token = null)
{
var target = token is null ? path : $"{path}?token={Uri.EscapeDataString(token)}";
return server.SendAsync(ctx =>
{
ctx.Request.Method = "GET";
ctx.Request.Path = target.Contains('?') ? target[..target.IndexOf('?')] : target;
ctx.Request.QueryString = target.Contains('?') ? new QueryString(target[target.IndexOf('?')..]) : QueryString.Empty;
ctx.Request.Headers.Host = host;
ctx.Connection.RemoteIpAddress = remote;
});
}

/// <summary>Network mode, no credential at all: the Host header is legitimate but no token/cookie is
/// presented, so the auth gate serves the login page (200 — see <c>WebRequestAction.ShowLogin</c>,
/// deliberately not a 401; <c>HostHeaderGuardTests</c> and <c>DarlingWebAuthTests</c> pin why).</summary>
[Fact]
public async Task NetworkMode_NoToken_ShowsLoginRatherThanServingTheApp()
{
using var server = await BuildServer(networkMode: true);
var ctx = await Send(server, "/", ListenIp, InCidrRemote);

Assert.Equal(StatusCodes.Status200OK, ctx.Response.StatusCode);
// The login page, not a static-file/app response: its body is generated inline by WriteLoginPageAsync.
Assert.Equal("text/html; charset=utf-8", ctx.Response.ContentType);
}

/// <summary>Network mode, the right token, an in-CIDR remote: the gate exchanges the token for a
/// session cookie and 302-redirects (<c>WebRequestAction.SetCookieAndRedirect</c>) — proof the request
/// got PAST every gate, not a login page and not a refusal.</summary>
[Fact]
public async Task NetworkMode_RightTokenInCidr_PassesTheGates()
{
using var server = await BuildServer(networkMode: true);
var ctx = await Send(server, "/", ListenIp, InCidrRemote, token: Token);

Assert.Equal(StatusCodes.Status302Found, ctx.Response.StatusCode);
Assert.True(ctx.Response.Headers.SetCookie.Count > 0, "a valid token must be exchanged for a session cookie");
}

/// <summary>Network mode, the right token, but the remote is OUTSIDE <c>allowFrom</c>: the CIDR gate is
/// outermost (#2550) and forbids before the token is even considered.</summary>
[Fact]
public async Task NetworkMode_RightTokenOutsideCidr_IsForbidden()
{
using var server = await BuildServer(networkMode: true);
var ctx = await Send(server, "/", ListenIp, OutOfCidrRemote, token: Token);

Assert.Equal(StatusCodes.Status403Forbidden, ctx.Response.StatusCode);
}

/// <summary>A foreign Host header is refused by the DNS-rebinding guard in NETWORK mode — before the
/// CIDR/token gate ever runs (#1576/#1648).</summary>
[Fact]
public async Task NetworkMode_ForeignHost_IsRefused()
{
using var server = await BuildServer(networkMode: true);
var ctx = await Send(server, "/", "evil.com", InCidrRemote, token: Token);

Assert.Equal(StatusCodes.Status400BadRequest, ctx.Response.StatusCode);
}

/// <summary>The same rebinding guard runs in LOOPBACK mode too (#1576 fixed a loopback-only gap): a
/// foreign Host is refused even though loopback registers no CIDR/token middleware at all.</summary>
[Fact]
public async Task LoopbackMode_ForeignHost_IsRefused()
{
using var server = await BuildServer(networkMode: false);
var ctx = await Send(server, "/", "evil.com", IPAddress.Loopback);

Assert.Equal(StatusCodes.Status400BadRequest, ctx.Response.StatusCode);
}

/// <summary>Loopback mode's own posture: no token middleware is registered at all, so a loopback Host
/// with no token reaches past the gates (a 404 from the endpoint mapping / static files proves it got
/// through — no gate short-circuited with a 400/403, and there is no login page in this mode).</summary>
[Fact]
public async Task LoopbackMode_NoToken_ReachesPastTheGates()
{
using var server = await BuildServer(networkMode: false);
var ctx = await Send(server, "/some/unmapped/path", "localhost", IPAddress.Loopback);

Assert.NotEqual(StatusCodes.Status400BadRequest, ctx.Response.StatusCode);
Assert.NotEqual(StatusCodes.Status403Forbidden, ctx.Response.StatusCode);
}
}
2 changes: 1 addition & 1 deletion Darling/Darling.Tests/FleetSweepWebFeedTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,7 @@ public void TheSweepRoutes_LogThroughTheServiceLogger_NotTheProviderlessAppFacto
"Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingWebHostService.cs");
Assert.Contains("builder.Logging.ClearProviders();", host, StringComparison.Ordinal);
Assert.Contains(
"DarlingWebEndpoints.MapAll(_app, postgres, _collectorState, _logger, _baselineCache);",
"DarlingWebEndpoints.MapAll(app, postgres, _collectorState, _logger, _baselineCache);",
host, StringComparison.Ordinal);
}

Expand Down
23 changes: 16 additions & 7 deletions Darling/Darling.Tests/HostHeaderGuardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,23 +143,26 @@ private static string ReadHostSource(string fileName)

var source = File.ReadAllText(path);
/* Guard the guard: if the host were renamed or restructured past recognition, the assertions below
could pass vacuously on an empty/mismatched parse. Pin the anchors they all key off. */
could pass vacuously on an empty/mismatched parse. Pin the anchors they all key off. "app.Use("
(not "_app.Use(") matches both hosts: the MCP host still calls the field directly (_app.Use), while
the web host's gates were extracted (#4128) into ConfigurePipeline(WebApplication app, ...), which
calls app.Use — "app.Use(" is a substring of "_app.Use(" too, so this anchor covers both shapes. */
Assert.Contains("_app = builder.Build();", source, StringComparison.Ordinal);
Assert.Contains("_app.Use(", source, StringComparison.Ordinal);
Assert.Contains("app.Use(", source, StringComparison.Ordinal);
return source;
}

/// <summary>The text of the first middleware registered after the app is built.</summary>
private static string FirstMiddlewareAfterBuild(string source)
{
var afterBuild = source[(source.IndexOf("_app = builder.Build();", StringComparison.Ordinal) + 1)..];
var firstUse = afterBuild.IndexOf("_app.Use(", StringComparison.Ordinal);
var firstUse = afterBuild.IndexOf("app.Use(", StringComparison.Ordinal);
Assert.True(firstUse >= 0, "no middleware is registered after builder.Build()");

/* The first Use's lambda body — long enough to cover the guard's if/return, short enough that a
SECOND Use's body cannot bleed in and satisfy the assertion by accident. */
var body = afterBuild[firstUse..];
var nextUse = body.IndexOf("_app.Use(", 1, StringComparison.Ordinal);
var nextUse = body.IndexOf("app.Use(", 1, StringComparison.Ordinal);
return nextUse > 0 ? body[..nextUse] : body;
}

Expand Down Expand Up @@ -188,10 +191,16 @@ public void EachHost_RunsTheGuardInBothModes_NotOnlyWhenNetworkExposed(string fi
var afterBuild = source[(source.IndexOf("_app = builder.Build();", StringComparison.Ordinal) + 1)..];

var guard = afterBuild.IndexOf("IsAllowedHost(context.Request.Host.Host", StringComparison.Ordinal);
var networkOnly = afterBuild.IndexOf("if (networkMode)", StringComparison.Ordinal);

Assert.True(guard >= 0, "the Host-header guard middleware is missing");
Assert.True(networkOnly >= 0, "expected a network-mode-only middleware block to exist after Build()");

/* #4128: the web host's gates were extracted into ConfigurePipeline, a method the file defines
AFTER TryStartServerAsync's own unrelated "if (networkMode)" (its post-start logging branch) —
so the FIRST "if (networkMode)" after Build() is no longer necessarily the one that gates the
auth middleware. Searching from the guard's own position finds the auth-gating conditional that
actually follows it in the same method, which is the invariant this test claims either way. */
var networkOnly = afterBuild.IndexOf("if (networkMode)", guard, StringComparison.Ordinal);

Assert.True(networkOnly >= 0, "expected a network-mode-only middleware block to exist after the guard");
Assert.True(
guard < networkOnly,
"the Host-header guard must be registered BEFORE the network-mode-only block — inside it, the " +
Expand Down
2 changes: 1 addition & 1 deletion Darling/Darling.Tests/SharedBaselineCacheTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ public void TheService_HandsOneBaselineCacheToEveryHostsAnalysis()
Assert.Contains("new DarlingAnalysisService(postgres, planFetcher, _logger, _baselineCache)", mcp, StringComparison.Ordinal);

var web = Code("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingWebHostService.cs");
Assert.Contains("DarlingWebEndpoints.MapAll(_app, postgres, _collectorState, _logger, _baselineCache);", web, StringComparison.Ordinal);
Assert.Contains("DarlingWebEndpoints.MapAll(app, postgres, _collectorState, _logger, _baselineCache);", web, StringComparison.Ordinal);
var endpoints = Code("Darling", "PerformanceMonitor.Darling.Service", "DarlingWebEndpoints.cs");
Assert.Contains("new DarlingAnalysisService(postgres, baselineCache: baselineCache)", endpoints, StringComparison.Ordinal);

Expand Down
6 changes: 6 additions & 0 deletions Darling/Darling.Tests/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
"version": 2,
"dependencies": {
"net10.0-windows7.0": {
"Microsoft.AspNetCore.TestHost": {
"type": "Direct",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "bTHgbcjSrLxlqSasYRDU9BcJ3hDGoqIjtDY0soLNrndOWi3kxtOyddwUMaNZgtoI2Uf6y7ZQs2EuVj0c20B7mA=="
},
"xunit.v3": {
"type": "Direct",
"requested": "[4.0.1, )",
Expand Down
Loading
Loading