refactor(v2core): M4.4.5.d mTLS HttpClient factory + certificate resolver seam

MtlsHttpClientFactory baut HttpClient/HttpClientHandler mit Client-Cert
attached fuer den BackendTelemetryUploader. Delegiert an
ICertificateResolver-Seam (DefaultCertificateResolver in Prod nutzt
CertificateHelper, Tests liefern Fake mit self-signed Cert).

Factory wirft InvalidOperationException wenn kein Cert gefunden — mTLS
ist Pflicht, Agent darf ohne Cert nicht starten. Decompression (GZip +
Deflate) + 30s Default-Timeout analog Legacy BackendApiClient.

CreateHandler separat exponiert, damit Tests direkt auf dem Handler
asserten koennen (HttpClient._handler ist internal + reflection-fragil
auf net48).

+5 Agent.V2.Core Tests (260 → 265 gruen; self-signed-Cert via
System.Security.Cryptography.CertificateRequest, kein Cert-Store-Touch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-20 20:46:48 +02:00
parent a72e716862
commit 4300a01769
3 changed files with 250 additions and 0 deletions
@@ -0,0 +1,125 @@
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
public sealed class MtlsHttpClientFactoryTests
{
private sealed class FakeCertificateResolver : ICertificateResolver
{
private readonly X509Certificate2? _cert;
public int CallCount { get; private set; }
public FakeCertificateResolver(X509Certificate2? cert) => _cert = cert;
public X509Certificate2? FindClientCertificate(AgentLogger logger)
{
CallCount++;
return _cert;
}
}
/// <summary>
/// Creates an in-memory self-signed cert so the factory has something real to attach
/// without touching the Windows cert store. <see cref="CertificateRequest"/> is
/// available on net48 (4.7.2+).
/// </summary>
private static X509Certificate2 CreateSelfSignedCert()
{
using (var rsa = RSA.Create(2048))
{
var req = new CertificateRequest(
"CN=apmon-v2-test",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
return req.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddDays(1));
}
}
[Fact]
public void CreateHandler_attaches_cert_and_enables_decompression()
{
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
using var cert = CreateSelfSignedCert();
var resolver = new FakeCertificateResolver(cert);
using var handler = MtlsHttpClientFactory.CreateHandler(resolver, logger);
Assert.Equal(1, resolver.CallCount);
Assert.Equal(
DecompressionMethods.GZip | DecompressionMethods.Deflate,
handler.AutomaticDecompression);
Assert.Single(handler.ClientCertificates);
Assert.Equal(cert.Thumbprint, ((X509Certificate2)handler.ClientCertificates[0]).Thumbprint);
}
[Fact]
public void Create_returns_client_with_default_timeout()
{
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
using var cert = CreateSelfSignedCert();
var resolver = new FakeCertificateResolver(cert);
using var client = MtlsHttpClientFactory.Create(resolver, logger);
Assert.NotNull(client);
Assert.Equal(MtlsHttpClientFactory.DefaultTimeout, client.Timeout);
}
[Fact]
public void Create_respects_custom_timeout()
{
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
using var cert = CreateSelfSignedCert();
var resolver = new FakeCertificateResolver(cert);
using var client = MtlsHttpClientFactory.Create(resolver, logger, TimeSpan.FromSeconds(5));
Assert.Equal(TimeSpan.FromSeconds(5), client.Timeout);
}
[Fact]
public void CreateHandler_throws_when_resolver_returns_null()
{
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
var resolver = new FakeCertificateResolver(null);
var ex = Assert.Throws<InvalidOperationException>(
() => MtlsHttpClientFactory.CreateHandler(resolver, logger));
Assert.Contains("no MDM client certificate found", ex.Message);
Assert.Equal(1, resolver.CallCount);
}
[Fact]
public void Create_rejects_null_dependencies()
{
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
using var cert = CreateSelfSignedCert();
Assert.Throws<ArgumentNullException>(
() => MtlsHttpClientFactory.Create(null!, logger));
Assert.Throws<ArgumentNullException>(
() => MtlsHttpClientFactory.Create(new FakeCertificateResolver(cert), null!));
}
}
}
@@ -0,0 +1,87 @@
#nullable enable
using System;
using System.Net;
using System.Net.Http;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Security;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Baut einen <see cref="HttpClient"/> mit mTLS-Client-Cert für den
/// <see cref="Transport.Telemetry.BackendTelemetryUploader"/>. Plan §4.x M4.4.5.d.
/// <para>
/// <b>Design</b>: Static Factory + <see cref="ICertificateResolver"/>-Seam statt direkte
/// Abhängigkeit an <see cref="CertificateHelper"/>. Tests injizieren einen
/// Fake-Resolver mit einem self-signed Cert; Prod nutzt
/// <see cref="DefaultCertificateResolver"/>, der den Windows-Cert-Store durchsucht.
/// </para>
/// <para>
/// <b>Konfiguration</b> (analog Legacy <c>BackendApiClient</c>):
/// <list type="bullet">
/// <item><see cref="DecompressionMethods.GZip"/> + <see cref="DecompressionMethods.Deflate"/></item>
/// <item>Default-Timeout 30s (override via Parameter)</item>
/// <item>Keine <c>CookieContainer</c>, keine Default-Credentials</item>
/// </list>
/// </para>
/// </summary>
public static class MtlsHttpClientFactory
{
/// <summary>Plan §4.x M4.4.5.d — Default-Timeout (analog Legacy).</summary>
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
/// <summary>
/// Baut einen <see cref="HttpClientHandler"/> mit attached Client-Cert. Wirft
/// <see cref="InvalidOperationException"/> wenn <paramref name="resolver"/> kein
/// Zertifikat liefert — der Agent darf ohne Cert nicht starten (mTLS ist Pflicht).
/// <para>
/// Separat exponiert, damit Tests direkt auf dem Handler asserten können
/// (<see cref="HttpClient"/>'s <c>_handler</c>-Feld ist internal und in net48
/// reflection-unzuverlässig).
/// </para>
/// </summary>
public static HttpClientHandler CreateHandler(
ICertificateResolver resolver,
AgentLogger logger)
{
if (resolver == null) throw new ArgumentNullException(nameof(resolver));
if (logger == null) throw new ArgumentNullException(nameof(logger));
var cert = resolver.FindClientCertificate(logger);
if (cert == null)
{
throw new InvalidOperationException(
"MtlsHttpClientFactory: no MDM client certificate found. " +
"Agent cannot start without a client cert (mTLS is mandatory). " +
"Check cert enrollment status in HKLM\\SOFTWARE\\Microsoft\\Enrollments.");
}
var handler = new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
};
handler.ClientCertificates.Add(cert);
logger.Info(
$"MtlsHttpClientFactory: handler ready (cert thumbprint={cert.Thumbprint}).");
return handler;
}
/// <summary>
/// Baut einen fertig konfigurierten <see cref="HttpClient"/> — Handler via
/// <see cref="CreateHandler"/>, Timeout via Parameter (default <see cref="DefaultTimeout"/>).
/// </summary>
public static HttpClient Create(
ICertificateResolver resolver,
AgentLogger logger,
TimeSpan? timeout = null)
{
var handler = CreateHandler(resolver, logger);
return new HttpClient(handler)
{
Timeout = timeout ?? DefaultTimeout,
};
}
}
}
@@ -0,0 +1,38 @@
#nullable enable
using System.Security.Cryptography.X509Certificates;
using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Abstraktion über <see cref="CertificateHelper.FindMdmCertificate"/>, damit Tests die
/// Zert-Auflösung ohne echten Windows-Cert-Store ersetzen können. Plan §4.x M4.4.5.d.
/// </summary>
public interface ICertificateResolver
{
/// <summary>
/// Sucht das MDM-Client-Zertifikat. <c>null</c> wenn nichts gefunden — der Caller
/// (typischerweise <see cref="Orchestration.MtlsHttpClientFactory"/>) entscheidet dann
/// über Fehlerbehandlung (wirft in Prod, Test-Resolver liefert null für die negative
/// Test-Variante).
/// </summary>
X509Certificate2? FindClientCertificate(AgentLogger logger);
}
/// <summary>
/// Produktiver <see cref="ICertificateResolver"/>. Delegiert an
/// <see cref="CertificateHelper.FindMdmCertificate"/>.
/// </summary>
public sealed class DefaultCertificateResolver : ICertificateResolver
{
private readonly string? _thumbprintOverride;
public DefaultCertificateResolver(string? thumbprintOverride = null)
{
_thumbprintOverride = thumbprintOverride;
}
public X509Certificate2? FindClientCertificate(AgentLogger logger) =>
CertificateHelper.FindMdmCertificate(_thumbprintOverride, logger);
}
}