more tests

This commit is contained in:
Oliver Kieselbach
2026-04-08 13:40:02 +02:00
parent edff8e192c
commit 21716c7d5b
10 changed files with 226 additions and 1 deletions
+3
View File
@@ -61,6 +61,9 @@ yarn-error.log*
.npm
.eslintcache
# TypeScript incremental build cache
*.tsbuildinfo
# Environment variables
.env
.env*.local
@@ -0,0 +1,42 @@
using System;
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.Core.Tests.Security
{
/// <summary>
/// Smoke test for <see cref="SystemPaths"/>: every pinned System32 binary must
/// exist on the test host. This catches typos, bad <c>Path.Combine</c> segments,
/// and accidental rewrites of the historical <c>WindowsPowerShell\v1.0</c> path —
/// all failure modes that would brick the Agent on every Windows device.
/// Windows-only (the Agent runs on net48 and CI runs on Windows agents).
/// </summary>
public class SystemPathsTests
{
[Fact]
public void AllPinnedPaths_ExistOnWindows()
{
Assert.Equal(PlatformID.Win32NT, Environment.OSVersion.Platform);
var paths = new (string name, string path)[]
{
(nameof(SystemPaths.Cmd), SystemPaths.Cmd),
(nameof(SystemPaths.Shutdown), SystemPaths.Shutdown),
(nameof(SystemPaths.TzUtil), SystemPaths.TzUtil),
(nameof(SystemPaths.Netsh), SystemPaths.Netsh),
(nameof(SystemPaths.PowerShell), SystemPaths.PowerShell),
};
var missing = paths
.Where(p => !File.Exists(p.path))
.Select(p => $"{p.name}: {p.path}")
.ToList();
Assert.True(
missing.Count == 0,
"SystemPaths point to missing binaries:\n " + string.Join("\n ", missing));
}
}
}
@@ -1 +1 @@
889
890
@@ -220,6 +220,27 @@ public class EndpointPolicyCatalogCompletenessTests
string.Join("\n", invalid.Select(i => $" - {i}")));
}
/// <summary>
/// The three global/apps/* routes must be locked to GlobalAdminOnly with
/// TenantScoping.None (the optional ?tenantId= query param is intentional cross-tenant
/// scoping, enforced inside the function — not by the middleware).
/// A future accidental downgrade of the policy tier would be caught here, whereas
/// the generic completeness test only guarantees that *some* entry exists.
/// </summary>
[Theory]
[InlineData("GET", "/api/global/apps/list", "global/apps/list")]
[InlineData("GET", "/api/global/apps/Company%20Portal/analytics", "global/apps/{appName}/analytics")]
[InlineData("GET", "/api/global/apps/Company%20Portal/sessions", "global/apps/{appName}/sessions")]
public void GlobalAppsRoutes_AreGlobalAdminOnly(string method, string path, string expectedTemplate)
{
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
Assert.NotNull(entry);
Assert.Equal(expectedTemplate, entry!.RouteTemplate);
Assert.Equal(EndpointPolicy.GlobalAdminOnly, entry.Policy);
Assert.Equal(TenantScoping.None, entry.TenantScoping);
}
/// <summary>
/// Named capture group for {tenantId} correctly extracts the value from request paths.
/// </summary>
@@ -0,0 +1,39 @@
using AutopilotMonitor.Functions.Functions.Apps;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Unit tests for the shared <c>?tenantId=</c> query-param validator used by
/// the three <c>global/apps/*</c> Global-Admin endpoints
/// (<see cref="GetGlobalAppsListFunction"/>, <see cref="GetGlobalAppAnalyticsFunction"/>,
/// <see cref="GetGlobalAppSessionsFunction"/>).
///
/// A malformed tenantId previously reached the table-storage query layer as a
/// raw string; this test ensures the guard short-circuits with a predictable
/// false (→ 400 BadRequest in the caller) for anything that isn't a GUID.
/// </summary>
public class GlobalAppsTenantIdValidationTests
{
[Theory]
[InlineData(null)] // not supplied → aggregate all tenants
[InlineData("")] // empty string → same
[InlineData("00000000-0000-0000-0000-000000000000")] // empty GUID is still valid-format
[InlineData("11111111-2222-3333-4444-555555555555")] // normal GUID
[InlineData("{11111111-2222-3333-4444-555555555555}")] // braced GUID (Guid.TryParse accepts)
public void Accepts_NullEmpty_And_ValidGuids(string? raw)
{
Assert.True(AppsAnalyticsHelper.IsValidOptionalTenantIdQueryParam(raw));
}
[Theory]
[InlineData("not-a-guid")]
[InlineData("notaguid")]
[InlineData("11111111-2222-3333-4444")] // truncated
[InlineData("11111111-2222-3333-4444-5555555555555")] // too long
[InlineData("'; DROP TABLE devices; --")] // injection attempt
[InlineData("../admin")]
public void Rejects_MalformedValues(string raw)
{
Assert.False(AppsAnalyticsHelper.IsValidOptionalTenantIdQueryParam(raw));
}
}
@@ -0,0 +1,86 @@
using System.Reflection;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Contract tests for <see cref="ValidateBootstrapCodeResponse"/>, the DTO returned
/// by <c>GET /api/bootstrap/validate/{code}</c>.
///
/// The values in this response are interpolated into a PowerShell bootstrap
/// script that runs as SYSTEM during Autopilot OOBE, and the Next.js frontend
/// at <c>src/Web/autopilot-monitor-web/lib/bootstrapValidation.ts</c> enforces
/// a very strict shape (GUID tenantId+token, locked-down AgentDownloadUrl, etc.).
///
/// These tests mirror the frontend's invariants so that a backend rename or
/// change to <c>AgentDownloadUrl</c> is caught in CI — not during OOBE rollout.
/// </summary>
public class ValidateBootstrapCodeResponseShapeTests
{
/// <summary>
/// Mirrors the frontend regex at <c>bootstrapValidation.ts</c> (lines ~119–125).
/// </summary>
private static readonly Regex AgentDownloadUrlPath =
new(@"^/agent/[A-Za-z0-9_-][A-Za-z0-9._-]{0,79}\.zip$", RegexOptions.Compiled);
/// <summary>
/// The response DTO must expose exactly these public instance properties with
/// these types. Any rename or removal breaks the frontend validator silently.
/// </summary>
[Fact]
public void ValidateBootstrapCodeResponse_HasExpectedPublicProperties()
{
var expected = new Dictionary<string, Type>
{
["Success"] = typeof(bool),
["TenantId"] = typeof(string),
["Token"] = typeof(string),
["AgentDownloadUrl"] = typeof(string),
["ExpiresAt"] = typeof(DateTime),
["Message"] = typeof(string),
};
var actual = typeof(ValidateBootstrapCodeResponse)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.ToDictionary(p => p.Name, p => p.PropertyType);
// Missing / renamed properties
var missing = expected.Keys.Except(actual.Keys).ToList();
Assert.True(missing.Count == 0,
"ValidateBootstrapCodeResponse is missing expected properties: " + string.Join(", ", missing));
// Unexpected additions (forces a deliberate test update when the contract grows)
var extra = actual.Keys.Except(expected.Keys).ToList();
Assert.True(extra.Count == 0,
"ValidateBootstrapCodeResponse has unexpected extra properties (update frontend validator + this test): "
+ string.Join(", ", extra));
// Type mismatches
foreach (var kvp in expected)
{
Assert.Equal(kvp.Value, actual[kvp.Key]);
}
}
/// <summary>
/// The hardcoded agent download URL in <c>ValidateBootstrapCodeFunction</c> must
/// satisfy the frontend's host allow-list and path regex.
/// </summary>
[Fact]
public void AgentDownloadUrl_MatchesFrontendAllowList()
{
// Keep in sync with ValidateBootstrapCodeFunction.cs — if that literal
// changes, both this assertion and bootstrapValidation.ts must be updated.
const string HardcodedAgentUrl =
"https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip";
Assert.True(Uri.TryCreate(HardcodedAgentUrl, UriKind.Absolute, out var uri),
"AgentDownloadUrl is not a well-formed absolute URI");
Assert.Equal(Uri.UriSchemeHttps, uri!.Scheme);
Assert.Equal("autopilotmonitor.blob.core.windows.net", uri.Host);
Assert.True(AgentDownloadUrlPath.IsMatch(uri.AbsolutePath),
$"AgentDownloadUrl path '{uri.AbsolutePath}' does not match the frontend allow-list regex");
}
}
@@ -11,6 +11,22 @@ namespace AutopilotMonitor.Functions.Functions.Apps
/// </summary>
internal static class AppsAnalyticsHelper
{
// ── Query param validation ──────────────────────────────────────────
/// <summary>
/// Validates the optional <c>?tenantId=</c> query parameter shared by all
/// three <c>global/apps/*</c> endpoints. Null / empty is allowed (means
/// "aggregate across all tenants"); any non-empty value must parse as a GUID.
/// Returns <c>true</c> when the value is acceptable; otherwise <c>false</c>
/// (caller should emit a 400).
/// </summary>
public static bool IsValidOptionalTenantIdQueryParam(string? raw)
{
if (string.IsNullOrEmpty(raw))
return true;
return Guid.TryParse(raw, out _);
}
// ── Data loaders ────────────────────────────────────────────────────
/// <summary>
@@ -46,6 +46,12 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var scopedTenantId = query["tenantId"];
if (!AppsAnalyticsHelper.IsValidOptionalTenantIdQueryParam(scopedTenantId))
{
var bad2 = req.CreateResponse(HttpStatusCode.BadRequest);
await bad2.WriteAsJsonAsync(new { success = false, message = "tenantId must be a valid GUID" });
return bad2;
}
int days = 30;
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
days = parsedDays;
@@ -49,6 +49,12 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var scopedTenantId = query["tenantId"];
if (!AppsAnalyticsHelper.IsValidOptionalTenantIdQueryParam(scopedTenantId))
{
var bad2 = req.CreateResponse(HttpStatusCode.BadRequest);
await bad2.WriteAsJsonAsync(new { success = false, message = "tenantId must be a valid GUID" });
return bad2;
}
int days = 30;
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
days = parsedDays;
@@ -35,6 +35,12 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var scopedTenantId = query["tenantId"];
if (!AppsAnalyticsHelper.IsValidOptionalTenantIdQueryParam(scopedTenantId))
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { success = false, message = "tenantId must be a valid GUID" });
return bad;
}
int days = 30;
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
days = parsedDays;