Merge pull request #85 from okieselbach/claude/test-coverage-review-3sjz8o

Add comprehensive security-hardening test suites
This commit is contained in:
Oliver Kieselbach
2026-07-08 13:15:01 +02:00
committed by GitHub
10 changed files with 1446 additions and 0 deletions
@@ -0,0 +1,239 @@
using System;
using System.IO;
using System.Net.Http;
using System.Reflection;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
{
/// <summary>
/// Pins the OOBE-MITM cache-hardening contract on <see cref="RemoteConfigService"/>
/// (RemoteConfigService.cs:234-311). The on-disk config cache MUST NOT persist — nor,
/// on read, trust — security-sensitive fields: a single MITM during OOBE could otherwise
/// plant attacker-controlled values (UnrestrictedMode / AllowAgentDowngrade /
/// LatestAgentExeSha256 / DeviceBlocked / DeviceKillSignal / UnblockAt) that survive every
/// future cold boot. The strip is defence-in-depth on BOTH paths — <c>CacheConfig</c> (write)
/// and <c>LoadCachedConfig</c> (read) — so both are asserted here.
/// <para>
/// The production cache path is a fixed <c>%ProgramData%\AutopilotMonitor\Config</c> location.
/// To exercise the REAL <c>CacheConfig</c>/<c>LoadCachedConfig</c> disk + serialization code in
/// isolation, the probe subclass exposes the two protected methods and the private
/// <c>_cacheFilePath</c> field is redirected to a per-test <see cref="TempDirectory"/> via
/// reflection. Nothing about the strip logic itself is faked.
/// </para>
/// </summary>
public sealed class RemoteConfigServiceCacheHardeningTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly AgentLogger _logger;
private readonly string _cachePath;
public RemoteConfigServiceCacheHardeningTests()
{
_logger = new AgentLogger(Path.Combine(_tmp.Path, "logs"), AgentLogLevel.Info);
_cachePath = Path.Combine(_tmp.Path, "remote-config.json");
}
public void Dispose() => _tmp.Dispose();
// ── Write path: CacheConfig strips before serializing to disk ────────
[Fact]
public void CacheConfig_strips_all_security_sensitive_fields_on_disk()
{
var probe = NewProbe();
// A config carrying a full set of attacker-controlled security values, as if a live
// (or MITM'd) fetch returned them. Benign fields are set to non-default markers so the
// round-trip can prove they are NOT collateral-stripped.
var config = new AgentConfigResponse
{
ConfigVersion = 77,
NtpServer = "pool.attacker.example",
UnrestrictedMode = true,
AllowAgentDowngrade = true,
LatestAgentExeSha256 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
DeviceBlocked = true,
DeviceKillSignal = true,
UnblockAt = new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc),
};
probe.DoCache(config);
// Read the file the production code actually wrote and deserialize with the same lib.
var raw = File.ReadAllText(_cachePath);
var persisted = JsonConvert.DeserializeObject<AgentConfigResponse>(raw);
Assert.False(persisted.UnrestrictedMode);
Assert.False(persisted.AllowAgentDowngrade);
Assert.Null(persisted.LatestAgentExeSha256);
Assert.False(persisted.DeviceBlocked);
Assert.False(persisted.DeviceKillSignal);
Assert.Null(persisted.UnblockAt);
// The attacker EXE hash must not appear anywhere in the persisted bytes.
Assert.DoesNotContain("deadbeef", raw);
// Benign fields survive the strip.
Assert.Equal(77, persisted.ConfigVersion);
Assert.Equal("pool.attacker.example", persisted.NtpServer);
// The in-memory object is restored after serialization — CacheConfig must not
// permanently mutate the live config it was handed (it is still in use by the agent).
Assert.True(config.UnrestrictedMode);
Assert.True(config.AllowAgentDowngrade);
Assert.Equal("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", config.LatestAgentExeSha256);
Assert.True(config.DeviceBlocked);
Assert.True(config.DeviceKillSignal);
Assert.Equal(new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc), config.UnblockAt);
}
// ── Read path: LoadCachedConfig strips a planted (attacker) cache file ─
[Theory]
[InlineData("UnrestrictedMode")]
[InlineData("AllowAgentDowngrade")]
[InlineData("LatestAgentExeSha256")]
[InlineData("DeviceBlocked")]
[InlineData("DeviceKillSignal")]
[InlineData("UnblockAt")]
public void LoadCachedConfig_strips_planted_security_sensitive_field(string field)
{
var probe = NewProbe();
// Simulate an OOBE-MITM (or an older cache format) having persisted a cache file that
// DOES carry attacker values on every sensitive field — written out-of-band, bypassing
// the write-side strip. The read-side strip is the defence-in-depth that must still hold.
var attacker = new AgentConfigResponse
{
ConfigVersion = 5,
UnrestrictedMode = true,
AllowAgentDowngrade = true,
LatestAgentExeSha256 = "0011223344556677889900112233445566778899001122334455667788990011",
DeviceBlocked = true,
DeviceKillSignal = true,
UnblockAt = new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc),
};
File.WriteAllText(_cachePath, JsonConvert.SerializeObject(attacker, Formatting.Indented));
var loaded = probe.DoLoad();
Assert.NotNull(loaded);
switch (field)
{
case "UnrestrictedMode":
Assert.False(loaded.UnrestrictedMode);
break;
case "AllowAgentDowngrade":
Assert.False(loaded.AllowAgentDowngrade);
break;
case "LatestAgentExeSha256":
Assert.Null(loaded.LatestAgentExeSha256);
break;
case "DeviceBlocked":
Assert.False(loaded.DeviceBlocked);
break;
case "DeviceKillSignal":
Assert.False(loaded.DeviceKillSignal);
break;
case "UnblockAt":
Assert.Null(loaded.UnblockAt);
break;
default:
throw new InvalidOperationException($"unknown field {field}");
}
// Benign data from the planted file is still loaded — the strip is surgical.
Assert.Equal(5, loaded.ConfigVersion);
}
// ── Round-trip: benign fields survive write + read unchanged ─────────
[Fact]
public void RoundTrip_preserves_non_sensitive_fields()
{
var probe = NewProbe();
var config = new AgentConfigResponse
{
ConfigVersion = 26,
UploadIntervalSeconds = 45,
MaxBatchSize = 250,
SelfDestructOnComplete = false,
KeepLogFile = true,
EnableGeoLocation = false,
NtpServer = "time.contoso.com",
LogLevel = "Debug",
DiagnosticsUploadMode = "OnFailure",
// A non-sensitive hash field (ZIP hash) that must NOT be stripped — only the
// EXE hash (LatestAgentExeSha256) is security-sensitive on the cache path.
LatestAgentSha256 = "cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe",
};
probe.DoCache(config);
var loaded = probe.DoLoad();
Assert.NotNull(loaded);
Assert.Equal(26, loaded.ConfigVersion);
Assert.Equal(45, loaded.UploadIntervalSeconds);
Assert.Equal(250, loaded.MaxBatchSize);
Assert.False(loaded.SelfDestructOnComplete);
Assert.True(loaded.KeepLogFile);
Assert.False(loaded.EnableGeoLocation);
Assert.Equal("time.contoso.com", loaded.NtpServer);
Assert.Equal("Debug", loaded.LogLevel);
Assert.Equal("OnFailure", loaded.DiagnosticsUploadMode);
Assert.Equal("cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe", loaded.LatestAgentSha256);
}
// ── Helpers ─────────────────────────────────────────────────────────
private CacheProbe NewProbe()
{
// A throwaway BackendApiClient — the cache-path methods never hit the wire.
var apiClient = new BackendApiClient(
httpClient: new HttpClient(),
baseUrl: "http://localhost",
manufacturer: string.Empty,
model: string.Empty,
serialNumber: string.Empty,
useBootstrapTokenAuth: false,
bootstrapToken: null,
agentVersion: "0.0.0",
logger: _logger);
var probe = new CacheProbe(apiClient, _logger);
RedirectCachePath(probe, _cachePath);
return probe;
}
// Redirect the private, constructor-computed %ProgramData% cache path to the per-test temp
// file so the REAL CacheConfig/LoadCachedConfig disk code runs in isolation.
private static void RedirectCachePath(RemoteConfigService svc, string path)
{
var field = typeof(RemoteConfigService).GetField(
"_cacheFilePath", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(field); // guards against a rename silently disabling the redirect
field.SetValue(svc, path);
}
// Minimal subclass: exposes the two protected cache seams so the real strip logic can be
// driven directly, without standing up the retry/fetch machinery.
private sealed class CacheProbe : RemoteConfigService
{
public CacheProbe(BackendApiClient apiClient, AgentLogger logger)
: base(apiClient, "00000000-0000-0000-0000-000000000001", logger)
{
}
public void DoCache(AgentConfigResponse config) => CacheConfig(config);
public AgentConfigResponse DoLoad() => LoadCachedConfig();
}
}
}
@@ -0,0 +1,93 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
/// Hostile-input hardening for <see cref="CmTraceLogParser.TryParseLine"/>
/// (CmTraceLogParser.cs:35-73). The parser runs on every IME log line — including partially
/// flushed, BOM-prefixed, junk-prefixed and pathologically long lines written by Windows while
/// the file is still being appended. It must never throw; it either returns a populated
/// <see cref="CmTraceLogEntry"/> or reports a clean "does not match" (false). These pin CURRENT
/// behaviour.
/// </summary>
public sealed class CmTraceLogParserTests
{
// Same well-formed CMTrace line shape as StallProbeActiveInstallFilterTests' helper.
private static string CmTraceLine(string message, string time, string date) =>
$"<![LOG[{message}]LOG]!><time=\"{time}\" date=\"{date}\" " +
"component=\"AppEnforce\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
// ── Non-matching / malformed input → clean false, never a throw ───────
[Theory]
[InlineData(null)] // null
[InlineData("")] // empty
[InlineData(" ")] // whitespace only
[InlineData("<![LOG[truncated line with no closing")] // truncated / missing fields
[InlineData("<![LOG[msg]LOG]!><time=\"06:08:04.8834397\" date=\"2-8-2026\" component=\"C\" context=\"\" type=\"1\" thread=\"1\" file=\"\">")] // leading BOM breaks the StartsWith gate
[InlineData("garbage prefix <![LOG[msg]LOG]!><time=\"06:08:04.8834397\" date=\"2-8-2026\" component=\"C\" context=\"\" type=\"1\" thread=\"1\" file=\"\">")] // leading junk
public void TryParseLine_returns_false_without_throwing_for_malformed_input(string line)
{
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
Assert.False(ok);
Assert.Null(entry);
}
// ── Well-formed line → parses ────────────────────────────────────────
[Fact]
public void TryParseLine_parses_well_formed_line()
{
var line = CmTraceLine("EnforcementState: Installing app X", "06:08:04.8834397", "2-8-2026");
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
Assert.True(ok);
Assert.NotNull(entry);
Assert.Equal("EnforcementState: Installing app X", entry.Message);
Assert.Equal("AppEnforce", entry.Component);
Assert.Equal(1, entry.Type);
Assert.NotEqual(default, entry.Timestamp);
}
// ── Structurally valid but unparseable timestamp → UtcNow fallback ────
[Fact]
public void TryParseLine_falls_back_to_utcnow_for_unparseable_timestamp()
{
// time/date satisfy the regex character classes ([\d:.]+ / [\d-]+) so the line MATCHES,
// but "13-45-2026" / "25:99:99" fail every DateTime format → the parser falls back to
// DateTime.UtcNow (CmTraceLogParser.cs:57) instead of throwing.
var before = DateTime.UtcNow;
var line = CmTraceLine("some message", "25:99:99.0000000", "13-45-2026");
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
var after = DateTime.UtcNow;
Assert.True(ok);
Assert.NotNull(entry);
Assert.Equal("some message", entry.Message);
// Don't assert an exact instant — only that the fallback stamped a fresh "now"
// (a successfully-parsed date would land in 2026, far outside this window).
Assert.InRange(entry.Timestamp, before.AddSeconds(-5), after.AddSeconds(5));
}
// ── Oversized line → parses, no throw, no truncation of the message ───
[Fact]
public void TryParseLine_handles_oversized_line()
{
var hugeMessage = new string('x', 200_000);
var line = CmTraceLine(hugeMessage, "06:08:04.8834397", "2-8-2026");
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
Assert.True(ok);
Assert.NotNull(entry);
Assert.Equal(hugeMessage.Length, entry.Message.Length);
}
}
}
@@ -0,0 +1,105 @@
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
/// Backend-supplied IME log patterns are untrusted regex delivered on the config channel — a
/// bad (or malicious) tenant/global pattern must never crash the tracker. Two defences are
/// pinned here:
/// <list type="bullet">
/// <item>compile-time: an INVALID pattern is swallowed in <see cref="ImeLogTracker.CompilePatterns"/>
/// (ImeLogTracker.cs:452-487) — the tracker constructs, valid siblings still compile, and the
/// tracker stays usable.</item>
/// <item>match-time: every pattern is compiled with a 1&#160;s <c>MatchTimeout</c>
/// (ImeLogTracker.cs:458); a catastrophic-backtracking pattern + adversarial input raises
/// <see cref="System.Text.RegularExpressions.RegexMatchTimeoutException"/>, which the per-line
/// match loop catches (ImeLogTracker.LogProcessing.cs:177-180 / the ProcessLogMessageForTest
/// seam:482-489) rather than propagating.</item>
/// </list>
/// </summary>
public sealed class ImeLogPatternReDoSTests
{
private static ImeLogPattern Pattern(string id, string regex, string action = null) => new ImeLogPattern
{
PatternId = id,
Pattern = regex,
Category = "always",
Action = action,
Enabled = true,
Parameters = new Dictionary<string, string>(),
};
private static ImeLogTracker BuildTracker(TempDirectory tmp, List<ImeLogPattern> patterns) =>
new ImeLogTracker(
logFolder: tmp.Path,
patterns: patterns,
logger: new AgentLogger(tmp.Path, AgentLogLevel.Info));
// ── Compile-time swallow ─────────────────────────────────────────────
[Theory]
[InlineData("(")] // unbalanced group
[InlineData("[a-")] // unterminated character class
[InlineData("(?<name>")] // unterminated named group
[InlineData("*invalid")] // quantifier with nothing to quantify
[InlineData("\\")] // dangling escape
public void Invalid_backend_pattern_is_swallowed_and_tracker_stays_usable(string badRegex)
{
using var tmp = new TempDirectory();
// Construction compiles the patterns; the bad one must not surface as an exception.
using var tracker = BuildTracker(tmp, new List<ImeLogPattern> { Pattern("BAD", badRegex) });
// And the tracker is still functional — driving a line through the (empty) active-pattern
// set is a no-op, not a throw.
var ex = Record.Exception(() => tracker.ProcessLogMessageForTest("any line here"));
Assert.Null(ex);
}
[Fact]
public void Valid_patterns_still_compile_when_a_sibling_pattern_is_invalid()
{
using var tmp = new TempDirectory();
var fired = false;
using var tracker = BuildTracker(tmp, new List<ImeLogPattern>
{
Pattern("BAD", "("), // dropped at compile
Pattern("IME-START", "IME Agent Started", action: "imeStarted"), // survives
});
tracker.OnImeStarted = () => fired = true;
tracker.ProcessLogMessageForTest("Info: IME Agent Started (v1.2.3)");
// The invalid sibling did not poison the batch — the valid pattern compiled and fired.
Assert.True(fired);
}
// ── Match-time timeout swallow (ReDoS) ───────────────────────────────
[Fact]
public void Catastrophic_backtracking_pattern_hits_match_timeout_and_is_swallowed()
{
using var tmp = new TempDirectory();
// Classic exponential-backtracking pattern; the trailing non-matching char forces the
// engine to explore the full 2^n search space, which blows past the 1 s MatchTimeout.
using var tracker = BuildTracker(tmp, new List<ImeLogPattern>
{
Pattern("REDOS", "^(a+)+$"),
});
var adversarialInput = new string('a', 40) + "!";
// The RegexMatchTimeoutException raised inside the match loop must be caught, not
// propagated — the tracker survives a hostile backend pattern + hostile log line.
var ex = Record.Exception(() => tracker.ProcessLogMessageForTest(adversarialInput));
Assert.Null(ex);
}
}
}
@@ -0,0 +1,71 @@
using System;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
// NOTE: FindMdmCertificate (CertificateHelper.cs:20-101) is NOT unit-testable as written — it
// opens LocalMachine\My and CurrentUser\My X509Store instances directly and performs the
// issuer-match ("CN=Microsoft Intune MDM Device CA", explicitly NOT the MMP-C
// "CN=Microsoft Intune Device Management Device CA"), the ClientAuth EKU (1.3.6.1.5.5.7.3.2)
// filter, and the NotAfter tiebreaker inline inside that store loop. There is no pure seam
// (e.g. an internal selection method over an IEnumerable<X509Certificate2> or an issuer
// predicate) to drive those rules without a real machine cert store. Per the project rule we do
// NOT fake the store. Refactor needed to make the security-critical selection testable: extract a
// `SelectMdmCertificate(IEnumerable<X509Certificate2>)` pure method and unit-test the issuer
// discrimination + NotAfter tiebreaker against it. Until then this file covers only the pure
// helper actually exposed: IsCertificateValid.
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// Covers the pure, store-independent portion of <see cref="CertificateHelper"/> —
/// <see cref="CertificateHelper.IsCertificateValid"/> (CertificateHelper.cs:106-113), the
/// NotBefore/NotAfter validity-window check. See the file-top NOTE for why FindMdmCertificate's
/// selection logic is out of unit-test reach.
/// </summary>
public sealed class CertificateHelperFindMdmCertTests
{
/// <summary>
/// In-memory self-signed cert with an explicit validity window (days relative to now),
/// so the store is never touched. Mirrors the CreateSelfSignedCert pattern used by
/// MtlsHttpClientFactoryTests. Windows are kept &gt;= 1 day away from "now" so the
/// helper's local-vs-UTC comparison skew cannot flip the expected result.
/// </summary>
private static X509Certificate2 CreateCert(int notBeforeDays, int notAfterDays)
{
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(notBeforeDays),
DateTimeOffset.UtcNow.AddDays(notAfterDays));
}
}
[Theory]
// Currently within the window → valid.
[InlineData(-1, 1, true)]
[InlineData(-30, 30, true)]
// Expired (NotAfter in the past) → invalid.
[InlineData(-10, -1, false)]
// Not yet valid (NotBefore in the future) → invalid.
[InlineData(1, 10, false)]
public void IsCertificateValid_reflects_validity_window(int notBeforeDays, int notAfterDays, bool expected)
{
using var cert = CreateCert(notBeforeDays, notAfterDays);
Assert.Equal(expected, CertificateHelper.IsCertificateValid(cert));
}
[Fact]
public void IsCertificateValid_returns_false_for_null()
{
Assert.False(CertificateHelper.IsCertificateValid(null));
}
}
}
@@ -0,0 +1,81 @@
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for the <c>POST /api/global/raw/logs</c> KQL-proxy endpoint served by
/// <see cref="AutopilotMonitor.Functions.Functions.Raw.AppInsightsQueryFunction"/> — an operator tool
/// that forwards an operator-supplied KQL query VERBATIM to the Application Insights REST API. Because
/// the query is passed through un-sandboxed and the App Insights workspace can surface secret-bearing
/// backend traces, the TRUST BOUNDARY for this endpoint is its authorization gate: only a platform
/// Global Admin may call it. These tests pin that gate.
///
/// SCOPE / WHAT IS AND IS NOT COVERED HERE (deliberate):
/// The function's own in-request behavior — appId-not-configured → 503, empty/whitespace query →
/// 400 BadRequest, App-Insights-error → 502 BadGateway structured error (innererror message + code +
/// SyntaxError/SemanticError hint), happy path → 200 with the raw AI tables JSON — is NOT exercised
/// here. The entrypoint <c>Run</c> sits directly on the abstract <c>HttpRequestData</c> /
/// <c>HttpResponseData</c> pair (whose <c>HttpResponseData.Cookies</c> returns the abstract
/// <c>HttpCookies</c>), reads the body via <c>ReadFromJsonAsync</c> and writes via
/// <c>CreateResponse</c>/<c>WriteAsJsonAsync</c> (all of which need a live worker serializer resolved
/// from <c>FunctionContext.InstanceServices</c>), and calls Application Insights through a PRIVATE
/// STATIC <c>HttpClient</c> + <c>DefaultAzureCredential</c> with no injection seam. Faking that whole
/// pipeline + a live Managed Identity is "more setup than the test is worth" — the exact rationale the
/// sibling function tests cite (see DeviceBlockFunctionTests / GetAllBlockedDevicesFunctionTests,
/// which likewise test the reachable seam instead of the HTTP entrypoint). The authorization gate
/// below IS the security-relevant, compilable seam for this endpoint.
///
/// The gate is asserted through <see cref="EndpointAccessPolicyCatalog.FindPolicy"/> (the single source
/// of truth the running middleware consults) and <see cref="AuthenticationMiddleware.SkipsJwtValidation"/>.
/// </summary>
public class AppInsightsQueryFunctionTests
{
private const string LogsRoute = "/api/global/raw/logs";
// ── Authorization gate: the KQL proxy is Global-Admin-only ──
[Fact]
public void RawLogs_post_is_registered_as_GlobalAdminOnly()
{
var entry = EndpointAccessPolicyCatalog.FindPolicy("POST", LogsRoute);
Assert.NotNull(entry);
Assert.Equal(EndpointPolicy.GlobalAdminOnly, entry!.Policy);
}
// ── The raw family (logs + tables) is deliberately kept OFF the read-only Global Reader tier:
// these endpoints can dump secret-bearing rows/traces and would bypass the GlobalReader config
// redaction, so they stay GlobalAdminOnly. Regression guard for that catalog decision. ──
[Theory]
[InlineData("POST", "/api/global/raw/logs")]
[InlineData("GET", "/api/global/raw/tables")]
[InlineData("GET", "/api/global/raw/tables/TenantConfiguration")]
public void Raw_family_endpoints_are_GlobalAdminOnly_not_global_reader(string method, string path)
{
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
Assert.NotNull(entry);
Assert.Equal(EndpointPolicy.GlobalAdminOnly, entry!.Policy);
// Explicitly NOT the read-only cross-tenant tier.
Assert.NotEqual(EndpointPolicy.GlobalReadOrAdmin, entry.Policy);
}
// ── A full, un-exempt JWT is required to reach the endpoint (it is not anonymous/device-exempt),
// which — combined with the GlobalAdminOnly policy — is what confines the raw KQL passthrough to
// platform admins. Documents the trust boundary end to end. ──
[Fact]
public void RawLogs_post_requires_jwt_and_is_not_exempt()
{
Assert.False(AuthenticationMiddleware.SkipsJwtValidation("POST", LogsRoute));
}
// ── Verb binding: the KQL proxy is POST-only. A GET to the same path is unregistered and therefore
// fail-closed (FindPolicy == null → the middleware denies). Guards against a future GET alias that
// would carry the KQL in the query string (and into logs/URLs). ──
[Fact]
public void RawLogs_get_is_unregistered_and_fails_closed()
{
Assert.Null(EndpointAccessPolicyCatalog.FindPolicy("GET", LogsRoute));
}
}
@@ -0,0 +1,112 @@
using AutopilotMonitor.Functions.Middleware;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for <see cref="AuthenticationMiddleware"/> via its transport-agnostic
/// <c>SkipsJwtValidation(method, path)</c> seam — the JWT-exemption decision, DERIVED from
/// <see cref="AutopilotMonitor.Functions.Security.EndpointAccessPolicyCatalog"/> (never a hand-kept
/// parallel allowlist), so a new anonymous/device route can't drift out of sync and get 401'd before
/// <c>PolicyEnforcementMiddleware</c> honors its policy.
///
/// SCOPE / WHAT IS AND IS NOT COVERED HERE (deliberate — mirrors the sibling function tests'
/// "more setup than the test is worth" rationale, e.g. DeviceBlockFunctionTests /
/// GetAllBlockedDevicesFunctionTests, and PolicyEnforcementMiddlewareTests which tests its own
/// transport-agnostic <c>DecideAsync</c> seam rather than <c>Invoke</c>):
///
/// The signature-validation rejects that <see cref="AuthenticationMiddleware.Invoke"/> performs —
/// • <c>alg: none</c> / HS-family blocked by the RS256/PS256 whitelist (ValidAlgorithms),
/// • expired token (ValidateLifetime),
/// • wrong audience (ValidAudiences = EntraId:ClientId + api://EntraId:ClientId),
/// • non-GUID / missing <c>tid</c> pre-reject (before any OIDC-metadata fetch),
/// are NOT unit-tested here. They live INLINE inside <c>Invoke</c> behind two heavy boundaries:
/// (1) a live OIDC <c>ConfigurationManager</c> that fetches tenant signing keys over the network,
/// and (2) the ASP.NET Core <c>HttpContext</c> obtained via <c>FunctionContext.GetHttpContext()</c>.
/// The <c>TokenValidationParameters</c> are built inline (not an extracted seam), so exercising those
/// rejects would require booting the worker HTTP pipeline + outbound OIDC — out of scope for a unit
/// test and untestable without live infra. The <c>SkipsJwtValidation</c> seam below is the smallest
/// validatable one and is exactly the security-relevant fork (exempt route vs. JWT-required route).
/// </summary>
public class AuthenticationMiddlewareTests
{
// ── Exempt: PublicAnonymous + DeviceOrBootstrapAuth routes bypass JWT validation ──
// (The middleware short-circuits to next() for these; JWT is validated later — or not at all —
// by the device/bootstrap auth path inside the function.)
[Theory]
// PublicAnonymous
[InlineData("GET", "/api/health")]
[InlineData("GET", "/api/stats/platform")]
[InlineData("GET", "/api/bootstrap/validate/ABC123")]
[InlineData("POST", "/api/agent/distress")]
[InlineData("GET", "/api/diagnostics/download")]
// DeviceOrBootstrapAuth
[InlineData("POST", "/api/agent/register-session")]
[InlineData("POST", "/api/agent/telemetry")]
[InlineData("GET", "/api/agent/config")]
[InlineData("POST", "/api/agent/upload-url")]
[InlineData("POST", "/api/agent/error")]
[InlineData("POST", "/api/bootstrap/register-session")]
[InlineData("GET", "/api/bootstrap/config")]
[InlineData("POST", "/api/bootstrap/error")]
public void SkipsJwtValidation_public_and_device_routes_are_exempt(string method, string path)
{
Assert.True(AuthenticationMiddleware.SkipsJwtValidation(method, path));
}
// ── NOT exempt: AuthenticatedUser and every higher tier require a valid JWT ──
[Theory]
[InlineData("GET", "/api/auth/me")] // AuthenticatedUser
[InlineData("GET", "/api/auth/is-global-admin")] // AuthenticatedUser
[InlineData("POST", "/api/realtime/negotiate")] // AuthenticatedUser
[InlineData("GET", "/api/progress/sessions")] // AuthenticatedUser
[InlineData("GET", "/api/sessions")] // MemberRead
[InlineData("GET", "/api/audit/logs")] // MemberRead
[InlineData("PUT", "/api/config/11111111-1111-1111-1111-111111111111")] // TenantAdminOrGA
[InlineData("GET", "/api/global/sessions")] // GlobalReadOrDelegatedSubset
[InlineData("POST", "/api/global/raw/logs")] // GlobalAdminOnly (KQL proxy)
[InlineData("GET", "/api/health/detailed")] // AuthenticatedUser (sub-route is NOT blanket-exempt)
[InlineData("GET", "/api/health/mcp")] // AuthenticatedUser
public void SkipsJwtValidation_authenticated_and_higher_routes_are_not_exempt(string method, string path)
{
Assert.False(AuthenticationMiddleware.SkipsJwtValidation(method, path));
}
// ── Fail-closed: unregistered route/verb requires JWT (FindPolicy == null → not exempt) ──
[Theory]
[InlineData("GET", "/api/does-not-exist")] // no catalog entry
[InlineData("POST", "/api/health")] // health is GET-only; POST is an unexpected verb
[InlineData("DELETE", "/api/agent/telemetry")] // device route but wrong verb
[InlineData("GET", "/api/global/raw/logs")] // logs proxy is POST-only; GET is unregistered
public void SkipsJwtValidation_unregistered_route_or_verb_is_not_exempt(string method, string path)
{
Assert.False(AuthenticationMiddleware.SkipsJwtValidation(method, path));
}
// ── Path normalization: the catalog strips the /api/ prefix, so the exemption decision is the
// same with or without it (the middleware passes HttpContext.Request.Path, which carries /api/). ──
[Theory]
[InlineData("health")]
[InlineData("/api/health")]
public void SkipsJwtValidation_normalizes_api_prefix_for_exempt_route(string path)
{
Assert.True(AuthenticationMiddleware.SkipsJwtValidation("GET", path));
}
[Theory]
[InlineData("sessions")]
[InlineData("/api/sessions")]
public void SkipsJwtValidation_normalizes_api_prefix_for_protected_route(string path)
{
Assert.False(AuthenticationMiddleware.SkipsJwtValidation("GET", path));
}
// ── Method-awareness: a public PATH does not wave through an unexpected VERB ──
[Fact]
public void SkipsJwtValidation_is_method_aware_on_public_path()
{
// GET health is public/exempt; POST to the same path is not registered → JWT required.
Assert.True(AuthenticationMiddleware.SkipsJwtValidation("GET", "/api/health"));
Assert.False(AuthenticationMiddleware.SkipsJwtValidation("POST", "/api/health"));
}
}
@@ -0,0 +1,197 @@
using System.Threading;
using System.Threading.Tasks;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Functions.Ingest;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Deletion;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Auth / pre-persist gate coverage for <see cref="IngestTelemetryFunction.Run"/>.
///
/// <para>
/// HARNESS NOTE — why these are decision-seam tests, not a live <c>Run()</c> invocation.
/// This suite has no fixture that boots the Functions worker's response serializer, so
/// <c>HttpRequestData.CreateResponse</c> + <c>HttpResponseData.WriteAsJsonAsync</c> (which every
/// Run() exit path calls, incl. the 400/403/410 error paths via <c>WriteErrorAsync</c>) cannot be
/// exercised without inventing runtime infrastructure. This is the same call the sibling function
/// tests make explicitly — see <see cref="DeviceBlockFunctionTests"/> /
/// <see cref="GetAllBlockedDevicesFunctionTests"/> ("mocking HttpRequestData + the middleware chain
/// is more setup than the test is worth"). So, exactly like <see cref="IngestCriticalPathTests"/>,
/// this file pins the <b>decision predicates and mapping helpers Run() branches on</b> at their real
/// seams, not the terminal HTTP status emission.
/// </para>
///
/// <para>
/// Coverage map to the four requested auth branches:
/// <list type="bullet">
/// <item>missing <c>X-Tenant-Id</c> → 400: the <c>string.IsNullOrEmpty(tenantIdHeader)</c> gate
/// (Run L100).</item>
/// <item>tenant mismatch (header vs PartitionKey) → 403 / malformed PartitionKey → 400: the real
/// <see cref="IngestTelemetryFunction.TryParsePartitionKey"/> static + the
/// <c>OrdinalIgnoreCase</c> equality Run performs (Run L169-180).</item>
/// <item>session deletion locked → 410: the real <see cref="SessionDeletionGuard"/> throwing
/// <see cref="SessionDeletionLockedException"/> (Run L205-213), plus the unique
/// <see cref="IngestTelemetryFunction.TryReadSessionStatus"/> that consumes the guard's row.</item>
/// <item>kill-switch / device-blocked → 200-block: NOT duplicated here. The verdict Run() routes
/// on (<c>IsBlocked</c>/<c>IsKill</c>/<c>UnblockAt</c>) is produced by
/// <see cref="Functions.Services.KillSwitchEvaluator"/> and is already exhaustively covered at the
/// exact seam Run() calls in <see cref="KillSwitchEvaluatorTests"/> (device Block/Kill, version
/// Block/Kill, device-block-short-circuit). Re-instantiating the evaluator here would add zero
/// coverage.</item>
/// </list>
/// </para>
/// </summary>
public class IngestTelemetryAuthPathTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
// ============================================================ Missing X-Tenant-Id → 400
[Theory]
[InlineData(null, true)]
[InlineData("", true)]
[InlineData(TenantId, false)]
public void MissingTenantHeader_gate_rejects_null_or_empty(string? tenantIdHeader, bool rejected)
{
// Run L100: `if (string.IsNullOrEmpty(tenantIdHeader)) return 400 "X-Tenant-Id header is required"`.
// An absent header surfaces here as null (Run maps Headers.Contains==false → null).
Assert.Equal(rejected, string.IsNullOrEmpty(tenantIdHeader));
}
// ============================================================ Tenant / PartitionKey auth decision
public enum AuthDecision { Accept, MalformedPartitionKey, TenantMismatch }
public static IEnumerable<object[]> PartitionKeyAuthCases()
{
// header, partitionKey, expected Run() decision
yield return new object[] { TenantId, $"{TenantId}_{SessionId}", AuthDecision.Accept };
// OrdinalIgnoreCase equality — same GUID, different casing must NOT be a mismatch (Run L174).
yield return new object[] { TenantId.ToUpperInvariant(), $"{TenantId}_{SessionId}", AuthDecision.Accept };
// Different tenant GUID in the body → 403.
yield return new object[] { TenantId, $"c3d4e5f6-a7b8-9012-cdef-012345678901_{SessionId}", AuthDecision.TenantMismatch };
// Unparseable PartitionKey → 400 before the mismatch check even runs (Run L169).
yield return new object[] { TenantId, "no-underscore-here", AuthDecision.MalformedPartitionKey };
yield return new object[] { TenantId, "_missing-tenant", AuthDecision.MalformedPartitionKey };
yield return new object[] { TenantId, "too_many_parts_here", AuthDecision.MalformedPartitionKey };
}
[Theory]
[MemberData(nameof(PartitionKeyAuthCases))]
public void TenantAuth_reproduces_Run_decision(string header, string partitionKey, AuthDecision expected)
{
// Mirrors Run L169-180 using the real production static + the exact OrdinalIgnoreCase compare.
var parsed = IngestTelemetryFunction.TryParsePartitionKey(partitionKey, out var bodyTenant, out _);
AuthDecision actual;
if (!parsed)
{
actual = AuthDecision.MalformedPartitionKey; // Run → 400 "Malformed PartitionKey"
}
else if (!string.Equals(bodyTenant, header, StringComparison.OrdinalIgnoreCase))
{
actual = AuthDecision.TenantMismatch; // Run → 403 "TenantId mismatch..."
}
else
{
actual = AuthDecision.Accept;
}
Assert.Equal(expected, actual);
}
// ============================================================ Session deletion locked → 410
[Theory]
[InlineData(SessionDeletionState.Preparing)]
[InlineData(SessionDeletionState.Queued)]
[InlineData(SessionDeletionState.Running)]
[InlineData(SessionDeletionState.Poisoned)]
public async Task DeletionLocked_guard_throws_the_exception_Run_maps_to_410(string lockState)
{
// Run L205-213: EnsureWritableAndGetRowAsync throws SessionDeletionLockedException for an
// in-flight cascade → Run responds 410 Gone via WriteSessionLockedAsync.
var guard = NewGuard(out var reader);
var row = new TableEntity(TenantId, SessionId)
{
["DeletionState"] = lockState,
["PendingDeletionManifestId"] = "MANIFEST-410",
};
reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(row);
var ex = await Assert.ThrowsAsync<SessionDeletionLockedException>(
() => guard.EnsureWritableAndGetRowAsync(TenantId, SessionId, callerContext: "V2.IngestTelemetry"));
Assert.Equal(lockState, ex.CurrentState);
Assert.Equal("MANIFEST-410", ex.ManifestId);
}
[Fact]
public async Task DeletionUnlocked_guard_returns_row_that_feeds_the_status_prefetch()
{
// Run L205-217: on an unlocked session the guard hands back the loaded row, and Run reads
// Status off it via TryReadSessionStatus to seed the stall-heal prefetch (no second read).
var guard = NewGuard(out var reader);
var row = new TableEntity(TenantId, SessionId)
{
["DeletionState"] = SessionDeletionState.None,
["Status"] = "Succeeded", // Sessions stores Status as string (status.ToString())
};
reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(row);
var returned = await guard.EnsureWritableAndGetRowAsync(TenantId, SessionId, callerContext: "V2.IngestTelemetry");
Assert.Same(row, returned);
Assert.Equal(SessionStatus.Succeeded, IngestTelemetryFunction.TryReadSessionStatus(returned));
}
// ============================================================ TryReadSessionStatus (unique helper)
[Theory]
[InlineData("InProgress", SessionStatus.InProgress)]
[InlineData("Succeeded", SessionStatus.Succeeded)]
[InlineData("failed", SessionStatus.Failed)] // case-insensitive parse
[InlineData("Stalled", SessionStatus.Stalled)]
public void TryReadSessionStatus_parses_string_status_case_insensitively(string stored, SessionStatus expected)
{
var row = new TableEntity(TenantId, SessionId) { ["Status"] = stored };
Assert.Equal(expected, IngestTelemetryFunction.TryReadSessionStatus(row));
}
[Fact]
public void TryReadSessionStatus_returns_null_for_null_row()
{
Assert.Null(IngestTelemetryFunction.TryReadSessionStatus(null));
}
[Fact]
public void TryReadSessionStatus_returns_null_when_status_column_absent()
{
var row = new TableEntity(TenantId, SessionId); // no Status column
Assert.Null(IngestTelemetryFunction.TryReadSessionStatus(row));
}
[Fact]
public void TryReadSessionStatus_returns_null_for_unparseable_status()
{
var row = new TableEntity(TenantId, SessionId) { ["Status"] = "NotARealStatus" };
Assert.Null(IngestTelemetryFunction.TryReadSessionStatus(row));
}
// ============================================================ Harness (mirrors SessionDeletionGuardTests)
private static SessionDeletionGuard NewGuard(out Mock<ISessionDeletionInventoryReader> reader)
{
reader = new Mock<ISessionDeletionInventoryReader>();
return new SessionDeletionGuard(reader.Object, NullLogger<SessionDeletionGuard>.Instance);
}
}
@@ -0,0 +1,151 @@
using System.IO;
using System.Text;
using AutopilotMonitor.Functions.Functions.Ingest;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Adversarial / malformed-body coverage for the ingest parse entrypoint. These exercise the same
/// seam the existing malformed-JSON test uses — <see cref="IngestTelemetryFunction.ReadBodyWithSizeCapAsync"/>
/// — because <c>Run()</c> feeds the raw (already gzip-decompressed) request stream straight into it
/// (Run L142). Anything this helper throws as a <c>JsonException</c> is caught at Run L144-148 and
/// mapped to a controlled <c>400 "Malformed JSON body"</c>; nothing escapes as an unhandled crash.
///
/// <para>Body construction mirrors <see cref="IngestTelemetryFunctionTests"/> verbatim
/// (<c>MemoryStream</c> over raw bytes → <c>ReadBodyWithSizeCapAsync(stream, maxBytes)</c>).</para>
/// </summary>
public class IngestTelemetryMalformedTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
// ============================================================ Deeply-nested JSON (JSON-bomb)
[Theory]
[InlineData("array")]
[InlineData("object")]
public async Task DeeplyNestedJson_is_a_controlled_JsonException_not_a_crash(string shape)
{
// 6000 levels of nesting — a JSON-bomb-style payload. Newtonsoft 13.x enforces a default
// reader MaxDepth of 64, so the parse fails fast with a JsonReaderException (a JsonException
// subclass) LONG before the nesting can exhaust the stack. Run() catches this at L144 and
// returns a controlled 400 "Malformed JSON body". CURRENT BEHAVIOR — this is safe by design
// (bounded depth), NOT a hardening gap.
const int depth = 6000;
var json = shape == "array"
? new string('[', depth) + new string(']', depth)
: "[" + string.Concat(Enumerable.Repeat("{\"a\":", depth)) + "1" + new string('}', depth) + "]";
var payload = Encoding.UTF8.GetBytes(json);
using var stream = new MemoryStream(payload);
await Assert.ThrowsAnyAsync<Newtonsoft.Json.JsonException>(
() => IngestTelemetryFunction.ReadBodyWithSizeCapAsync(stream, maxBytes: 10 * 1024 * 1024));
}
// ============================================================ Non-UTF8 / invalid bytes
[Fact]
public async Task InvalidUtf8Bytes_decode_to_replacement_chars_and_surface_as_JsonException()
{
// Lone UTF-8 continuation bytes (0x80-0xBF) are not valid UTF-8 and are not a byte-order
// mark. The StreamReader's default replacement fallback turns them into U+FFFD (it never
// throws on decode), and the resulting text is not valid JSON → controlled JsonException,
// no crash. CURRENT BEHAVIOR.
var payload = new byte[] { 0x80, 0x81, 0x82, 0x83, 0x84, 0x85 };
using var stream = new MemoryStream(payload);
await Assert.ThrowsAnyAsync<Newtonsoft.Json.JsonException>(
() => IngestTelemetryFunction.ReadBodyWithSizeCapAsync(stream, maxBytes: 1024));
}
[Fact]
public async Task Utf8BomPrefixedValidJson_is_stripped_and_parsed()
{
// A legitimate UTF-8 BOM (EF BB BF) in front of a valid empty batch: the StreamReader
// detects and strips the BOM, so the parse succeeds and yields an empty list (which Run
// then rejects downstream at L160 with 400 "No telemetry items provided"). Proves the UTF8
// decode path handles a real-world byte-order mark without corruption.
var payload = new byte[] { 0xEF, 0xBB, 0xBF }
.Concat(Encoding.UTF8.GetBytes("[]"))
.ToArray();
using var stream = new MemoryStream(payload);
var (exceeded, items) = await IngestTelemetryFunction.ReadBodyWithSizeCapAsync(stream, maxBytes: 1024);
Assert.False(exceeded);
Assert.NotNull(items);
Assert.Empty(items!);
}
[Fact]
public async Task MultiByteUtf8_string_values_round_trip_through_the_parser()
{
// Contrast to the invalid-byte case: well-formed multi-byte UTF-8 (accents + emoji) inside a
// string value must decode losslessly, confirming the StreamReader(UTF-8) path is correct
// for legitimate non-ASCII payloads.
var json = "[{\"Kind\":\"Signal\",\"PayloadJson\":\"café-\\u00e9-🚀\"}]";
var payload = Encoding.UTF8.GetBytes(json);
using var stream = new MemoryStream(payload);
var (exceeded, items) = await IngestTelemetryFunction.ReadBodyWithSizeCapAsync(stream, maxBytes: 1024);
Assert.False(exceeded);
var item = Assert.Single(items!);
Assert.Equal("Signal", item.Kind);
Assert.Equal("café-é-🚀", item.PayloadJson);
}
// ============================================================ Content-Type is not inspected
[Fact]
public async Task WrongContentType_ValidJsonBytes_still_parse_because_seam_reads_raw_stream()
{
// ReadBodyWithSizeCapAsync (and Run() around it) never inspects Content-Type — it reads the
// raw decompressed byte stream. So a body sent with, e.g., text/plain but carrying valid
// JSON bytes parses exactly as an application/json body would. CURRENT BEHAVIOR: parse is
// content-type-agnostic.
var payload = Encoding.UTF8.GetBytes("[{\"Kind\":\"Event\"}]");
using var stream = new MemoryStream(payload);
var (exceeded, items) = await IngestTelemetryFunction.ReadBodyWithSizeCapAsync(stream, maxBytes: 1024);
Assert.False(exceeded);
var item = Assert.Single(items!);
Assert.Equal("Event", item.Kind);
}
// ============================================================ Adversarial-but-valid shapes
[Theory]
[InlineData("[]")] // empty array → empty list (Run → 400 "No telemetry items")
[InlineData("[] \n\t ")] // trailing whitespace is ignored by the parser
public async Task EmptyArray_yields_empty_list_not_null(string json)
{
var payload = Encoding.UTF8.GetBytes(json);
using var stream = new MemoryStream(payload);
var (exceeded, items) = await IngestTelemetryFunction.ReadBodyWithSizeCapAsync(stream, maxBytes: 1024);
Assert.False(exceeded);
Assert.NotNull(items);
Assert.Empty(items!);
}
[Theory]
[InlineData("{\"Kind\":\"Event\"}")] // top-level object, not an array
[InlineData("\"just-a-string\"")] // top-level primitive
[InlineData("[{\"Kind\":\"Event\"},")] // truncated array
[InlineData("[{\"Kind\":}]")] // missing value
public async Task StructurallyBrokenOrWrongRootJson_throws_JsonException(string json)
{
// Each is caught at Run L144 and mapped to 400 "Malformed JSON body". A top-level object or
// primitive cannot bind to List<TelemetryItemDto> and surfaces as a JsonException too.
var payload = Encoding.UTF8.GetBytes(json);
using var stream = new MemoryStream(payload);
await Assert.ThrowsAnyAsync<Newtonsoft.Json.JsonException>(
() => IngestTelemetryFunction.ReadBodyWithSizeCapAsync(stream, maxBytes: 1024));
}
}
@@ -0,0 +1,204 @@
/**
* Adversarial UNIT tests for the Global-Admin raw tools (query_table, query_backend_logs).
*
* The existing tools-raw.test.ts is a live-backend integration suite gated behind
* `describe.skipIf(!AUTOPILOT_API_TOKEN)` — so in CI (no token) it goes green without
* exercising anything. These tests fill that gap at the unit level: fetch is stubbed, so
* they ALWAYS run in CI and prove the request the tool *builds* is safe:
*
* - `tableName` is encodeURIComponent'd → path-traversal is contained to one path segment.
* - `continuation` cross-tool path substitution is rejected by followNextLink's base-path guard.
* - operator-supplied OData `filter` / KQL `query` are forwarded VERBATIM (documenting the
* trust boundary: sanitization + RBAC live on the backend, GlobalAdmin-only). These tests
* pin that forwarding so a regression that silently mangles or drops the payload is caught,
* and so the "no client-side length cap" behavior is explicit rather than accidental.
*
* Harness mirrors tools-delegated.test.ts: build a server for a strict-GA caller, pull the raw
* registered handler, stub global fetch, invoke inside runWithCaller, inspect url + body.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerTools } from '../tools.js';
import { runWithCaller } from '../client.js';
type ToolHandler = (args: Record<string, unknown>, extra: unknown) => Promise<{
content?: Array<{ type: string; text?: string }>;
isError?: boolean;
}>;
/** Build a strict-GA server and return a tool's raw handler by name. */
function gaHandler(name: string): ToolHandler {
const server = new McpServer({ name: 'test', version: '0.0.0' });
registerTools(server, undefined, undefined, /*ga*/ true, /*strictGa*/ true, /*delegated*/ false);
const registry = (server as unknown as { _registeredTools: Record<string, { handler: ToolHandler }> })._registeredTools;
const tool = registry[name];
if (!tool) throw new Error(`tool ${name} not registered for strict-GA caller`);
return tool.handler;
}
interface Captured { url: string; method: string; body: string | undefined; }
/** Stub global fetch; capture url + method + body, return an empty-OK JSON body. */
function stubFetchCapture(): { calls: Captured[] } {
const calls: Captured[] = [];
const fn = vi.fn(async (url: string, init?: RequestInit) => {
calls.push({
url: String(url),
method: (init?.method ?? 'GET').toUpperCase(),
body: typeof init?.body === 'string' ? init.body : undefined,
});
return {
ok: true,
status: 200,
json: async () => ({ table: 'X', count: 0, entities: [], tables: [], nextLink: null }),
text: async () => '{}',
} as unknown as Response;
});
vi.stubGlobal('fetch', fn);
return { calls };
}
function resultText(r: { content?: Array<{ text?: string }> }): string {
return (r.content ?? []).map((c) => c.text ?? '').join('\n');
}
const GA = { token: 'ga-token', isGlobalAdmin: true } as const;
const extra = { signal: new AbortController().signal };
afterEach(() => vi.unstubAllGlobals());
// ── query_table: table-name path-traversal containment ──────────────────────
describe('query_table — tableName path-traversal is contained by encodeURIComponent', () => {
const traversals: Array<[label: string, tableName: string]> = [
['parent-dir slashes', 'Sessions/../../admin/secrets'],
['leading slash', '/etc/passwd'],
['encoded slash', 'Sessions..%2F..%2Fadmin'],
['backslash', 'Sessions\\..\\admin'],
['dot segments only', '../../../'],
];
it.each(traversals)('%s stays within the /raw/tables/ segment', async (_label, tableName) => {
const handler = gaHandler('query_table');
const { calls } = stubFetchCapture();
await runWithCaller(GA, () => handler({ tableName }, extra));
expect(calls).toHaveLength(1);
const url = calls[0].url;
// The request targets the tables endpoint...
expect(url).toContain('/api/global/raw/tables/');
// ...and the table name is a single, fully-encoded path segment: no raw '/'
// (and no raw '\\') survives between the base and the query string, so the
// caller cannot pivot to a sibling/parent path like /admin/secrets.
const afterBase = url.split('/api/global/raw/tables/')[1] ?? '';
const segment = afterBase.split('?')[0];
expect(segment).not.toContain('/');
expect(segment).not.toContain('\\');
});
});
// ── query_table: continuation cross-tool path substitution guard ────────────
describe('query_table — crafted continuation cannot bend the request to another endpoint', () => {
const foreign: Array<[label: string, continuation: string]> = [
['other tool path', '/api/global/raw/logs?x=1'],
['admin path', '/api/admin/secrets'],
['different table id', '/api/global/raw/tables/OtherTable?pageSize=1'],
];
it.each(foreign)('rejects continuation pointing at %s', async (_label, continuation) => {
const handler = gaHandler('query_table');
const { calls } = stubFetchCapture();
const res = await runWithCaller(GA, () => handler({ tableName: 'Sessions', continuation }, extra));
// followNextLink throws on base-path mismatch; toolError turns it into an isError result
// WITHOUT ever issuing the substituted request.
expect(calls).toHaveLength(0);
expect(res.isError).toBe(true);
expect(resultText(res)).toMatch(/does not match|base path/i);
});
it('accepts a continuation that matches its OWN base path', async () => {
const handler = gaHandler('query_table');
const { calls } = stubFetchCapture();
const ownLink = '/api/global/raw/tables/Sessions?pageSize=200&continuation=abc';
await runWithCaller(GA, () => handler({ tableName: 'Sessions', continuation: ownLink }, extra));
expect(calls).toHaveLength(1);
expect(calls[0].url).toContain(ownLink);
});
});
// ── query_table: OData filter is forwarded verbatim (trust boundary) ─────────
describe('query_table — OData filter forwarded verbatim and URL-safely encoded', () => {
const filters: Array<[label: string, filter: string]> = [
['tenant-scope breakout', "PartitionKey ne '' or true"],
['quote injection', "Status eq 'x' or 1 eq 1"],
['url-breaking chars', "Status eq 'a&b=c#d?e'"],
['unicode', "Name eq 'Ünîçodè-⚡-名前'"],
];
it.each(filters)('%s round-trips through the filter param', async (_label, filter) => {
const handler = gaHandler('query_table');
const { calls } = stubFetchCapture();
await runWithCaller(GA, () => handler({ tableName: 'Sessions', filter }, extra));
expect(calls).toHaveLength(1);
const parsed = new URL(calls[0].url, 'https://base.invalid');
// Encoded on the wire (no structural break), decoded value is exactly what we sent.
expect(parsed.searchParams.get('filter')).toBe(filter);
});
});
// ── query_backend_logs: KQL forwarded verbatim over POST ────────────────────
describe('query_backend_logs — KQL forwarded verbatim in POST body (backend is the gate)', () => {
const queries: Array<[label: string, query: string]> = [
['externaldata exfil attempt', "externaldata(x:string)[@'https://evil/e.csv'] with (format='csv')"],
['cross-table join', 'traces | join (requests) on operation_Id'],
['comment/terminator smuggling', "traces | take 1 // ; drop"],
['unicode + newlines', 'traces\n| where message has "Ünî"\n| take 1'],
];
it.each(queries)('%s reaches /raw/logs unchanged', async (_label, query) => {
const handler = gaHandler('query_backend_logs');
const { calls } = stubFetchCapture();
await runWithCaller(GA, () => handler({ query }, extra));
expect(calls).toHaveLength(1);
expect(calls[0].method).toBe('POST');
expect(calls[0].url).toContain('/api/global/raw/logs');
const sent = JSON.parse(calls[0].body ?? '{}');
expect(sent.query).toBe(query);
// (The PT1H timespan default is applied by the zod inputSchema at the MCP framework
// boundary, not inside the raw handler, so it is asserted separately below.)
});
// NOTE (hardening gap): there is NO client-side length cap on `query`. This test pins the
// current behavior — an oversized KQL string is forwarded intact — so any future cap is a
// deliberate, tested change rather than a silent one. The real DoS gate is backend RBAC
// (GlobalAdmin-only) + App Insights query limits.
it('forwards an oversized KQL string without client-side truncation', async () => {
const handler = gaHandler('query_backend_logs');
const { calls } = stubFetchCapture();
const huge = 'traces | where message contains "' + 'A'.repeat(100_000) + '" | take 1';
await runWithCaller(GA, () => handler({ query: huge }, extra));
expect(calls).toHaveLength(1);
const sent = JSON.parse(calls[0].body ?? '{}');
expect(sent.query.length).toBe(huge.length);
});
it('honors an explicit timespan', async () => {
const handler = gaHandler('query_backend_logs');
const { calls } = stubFetchCapture();
await runWithCaller(GA, () => handler({ query: 'traces | take 1', timespan: 'P1D' }, extra));
const sent = JSON.parse(calls[0].body ?? '{}');
expect(sent.timespan).toBe('P1D');
});
});
@@ -0,0 +1,193 @@
/**
* Tests for the OOBE bootstrap script route handler: app/go/[code]/route.ts.
*
* This is the web tier's single security-critical server endpoint: it proxies to the
* backend and inlines validated values into a PowerShell script that runs as SYSTEM during
* Windows OOBE. The VALIDATOR (utils/bootstrapValidation) is already well-tested; the HANDLER
* itself (code-format gate, backend-error passthrough, 200-on-error `irm|iex` contract,
* errorScript quote-escaping + length cap, and the "never leak the offending value" rule) was
* not. These tests cover the first line of defense with fetch stubbed.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { GET } from "../route";
function makeCtx(code: string) {
return { params: Promise.resolve({ code }) };
}
function invoke(code: string) {
return GET(new Request(`https://portal.test/go/${code}`), makeCtx(code));
}
function okBackend(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function errBackend(status: number, body: unknown = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
/** A bootstrap response that passes validateBootstrapResponse. */
function validPayload() {
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
return {
success: true,
tenantId: "11111111-1111-1111-1111-111111111111",
token: "22222222-2222-2222-2222-222222222222",
agentDownloadUrl:
"https://autopilotmonitor.blob.core.windows.net/agent/agent-v2.zip",
expiresAt,
};
}
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
// ── Code-format gate: rejected BEFORE any backend call ──────────────────────
describe("code-format validation", () => {
const badCodes: Array<[label: string, code: string]> = [
["empty", ""],
["too short (3)", "abc"],
["too long (11)", "abcdefghijk"],
["space", "ab cd"],
["slash / path traversal", "../etc"],
["encoded slash", "ab%2Fcd"],
["ps metachar $", "abc$"],
["quote", "ab'cd"],
["dot", "ab.cd"],
];
it.each(badCodes)("rejects %s without hitting the backend", async (_label, code) => {
const res = await invoke(code);
expect(fetchMock).not.toHaveBeenCalled();
// 200 so `irm | iex` still runs and surfaces the Write-Host error.
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain("ERROR: Invalid bootstrap code format.");
});
const goodCodes: Array<[string]> = [["abcd"], ["ABC123"], ["a1b2c3d4e5"]];
it.each(goodCodes)("accepts well-formed code %s (proceeds to backend)", async (code) => {
fetchMock.mockResolvedValueOnce(errBackend(404, {}));
await invoke(code);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(String(fetchMock.mock.calls[0][0])).toContain(
`/api/bootstrap/validate/${code}`,
);
});
});
// ── Backend error passthrough ───────────────────────────────────────────────
describe("backend validation errors", () => {
it("passes through the backend message", async () => {
fetchMock.mockResolvedValueOnce(errBackend(410, { message: "Code was revoked by admin." }));
const res = await invoke("abcd");
expect(res.status).toBe(200);
expect(await res.text()).toContain("ERROR: Code was revoked by admin.");
});
it("falls back to a default message when the backend body has none", async () => {
fetchMock.mockResolvedValueOnce(errBackend(404, {}));
const res = await invoke("abcd");
expect(await res.text()).toContain(
"ERROR: Bootstrap code not found, expired, or revoked.",
);
});
it("handles a non-JSON backend error body gracefully", async () => {
fetchMock.mockResolvedValueOnce(
new Response("<html>502</html>", { status: 502 }),
);
const res = await invoke("abcd");
expect(res.status).toBe(200);
expect(await res.text()).toContain("ERROR:");
});
it("returns a network-error script when fetch throws", async () => {
fetchMock.mockRejectedValueOnce(new Error("ECONNREFUSED"));
const res = await invoke("abcd");
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain("Failed to validate bootstrap code");
// The raw exception text must never be inlined into the script.
expect(body).not.toContain("ECONNREFUSED");
});
});
// ── errorScript hardening: quote-escaping + length cap ───────────────────────
describe("errorScript hardening", () => {
it("escapes single quotes for the PowerShell string literal", async () => {
fetchMock.mockResolvedValueOnce(errBackend(400, { message: "it's a 'bad' code" }));
const body = await (await invoke("abcd")).text();
// Single quotes doubled; the surrounding literal stays intact.
expect(body).toContain("it''s a ''bad'' code");
});
it("caps an oversized backend message (raw length, before escaping)", async () => {
const huge = "X".repeat(300);
fetchMock.mockResolvedValueOnce(errBackend(400, { message: huge }));
const body = await (await invoke("abcd")).text();
expect(body).toContain("X".repeat(200) + "...");
expect(body).not.toContain("X".repeat(201));
});
});
// ── Success path ────────────────────────────────────────────────────────────
describe("valid bootstrap → generated OOBE script", () => {
it("emits the PS script with the validated values and hardened headers", async () => {
const payload = validPayload();
fetchMock.mockResolvedValueOnce(okBackend(payload));
const res = await invoke("abcd");
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(res.headers.get("Cache-Control")).toContain("no-store");
const body = await res.text();
expect(body).toContain("#Requires -RunAsAdministrator");
expect(body).toContain(`--tenant-id '${payload.tenantId}'`);
expect(body).toContain(`--bootstrap-token '${payload.token}'`);
expect(body).toContain(`$AgentDownloadUrl = '${payload.agentDownloadUrl}'`);
});
it("rejects a backend payload that fails validation WITHOUT leaking the offending value", async () => {
// Valid shape/success, but hostile agentDownloadUrl (foreign host + PS injection).
const hostile = {
success: true,
tenantId: "11111111-1111-1111-1111-111111111111",
token: "22222222-2222-2222-2222-222222222222",
agentDownloadUrl: "https://evil.example.com/x.zip'; $(calc); '",
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
};
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
fetchMock.mockResolvedValueOnce(okBackend(hostile));
const res = await invoke("abcd");
const body = await res.text();
expect(res.status).toBe(200);
expect(body).toContain("ERROR: Bootstrap response failed validation. Contact support.");
// The generated script must not be produced, and the payload must not appear anywhere.
expect(body).not.toContain("#Requires -RunAsAdministrator");
expect(body).not.toContain("evil.example.com");
expect(body).not.toContain("calc");
warn.mockRestore();
});
});