mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
more tests
This commit is contained in:
@@ -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
|
||||
|
||||
+21
@@ -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));
|
||||
}
|
||||
}
|
||||
+86
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user