Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/AuthenticationMiddlewareTests.cs
T
Oliver Kieselbach 789926fcad backend: progress-portal serial-knowledge model — server-side lookup, serial proof on events + session-group join
Security finding: GET progress/sessions shipped the tenant's latest 100
sessions to any authenticated (roleless) user with the serial filter
applied only in the browser; events and SignalR session-group joins were
open to any known sessionId.

Model now: knowing the device's serial number is the authorization proof.
- progress/sessions/lookup (AuthenticatedUserWithRole + QueryParam)
  resolves at most ONE session server-side: roleless callers need the
  exact serial/device name, members/GA keep substring search. The
  tenant-wide list route is gone (catalog fail-closes it).
- progress events require ?serial= re-proof on every read; missing
  session and serial mismatch answer the same 404 (no existence oracle).
- Session-group joins by roleless same-tenant callers require the serial
  (SerialKnowledgeProof, shared with the events route); member/GA/
  delegated joins are unchanged. MemberRead on progress routes stays
  deliberately rejected (regression history c4dabeee).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:42:24 +02:00

113 lines
6.3 KiB
C#

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/lookup")] // AuthenticatedUserWithRole
[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"));
}
}