mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-07 18:46:48 +00:00
e4c4dd57db
Adds opt-in resolution of Intune Platform Script + Remediation display names in
session timelines via a tenant-side appRoleAssignment grant. No app manifest
change, no impact on customers who don't enable it. Architecture is reusable for
future optional Graph permissions: catalog -> detector -> resolver -> endpoint.
Backend
- GraphFeatureDetector parses the `roles` claim from the SP access token and
caches the (token, roles) bundle per tenant. GetSnapshotAsync surfaces a
transient flag so admin-UI callers can render "unknown" instead of a
misleading "0 of 0 granted" when token acquire times out.
- GraphTokenService now accepts a CancellationToken so optional-feature callers
can apply tight budgets instead of inheriting the 5+15+30 s retry chain.
- ScriptDisplayNameResolver: cache-first, list-pull-on-cold-tenant, per-ID
fallback. Throttle-aware abort: skip per-ID for kinds whose full-pull hit a
transient failure; break the per-ID loop on the first HttpRequestException.
Total budget 5 s, with a tight 1.5 s sub-budget on the token acquire.
- Azure Table `ScriptNameCache` + direct-TableClient fail-loud repository
(mirrors TableOffboardingAuditRepository pattern). Daily
ScriptNameCacheCleanupFunction: 7 d positive / 1 h NotFound TTL.
- POST /api/tenants/{id}/scripts/display-names (body-carried refs to dodge URL
length limits; backend cap 200 refs/request).
- GET /api/tenants/{id}/graph-permissions/status (4 s budget; includes app
ClientId so the UI can render an exact grant command).
- POST /api/tenants/{id}/graph-permissions/refresh (invalidates token cache).
- All three routes registered in EndpointAccessPolicyCatalog (MemberRead for the
resolve, TenantAdminOrGA for the admin-UI surfaces).
Web
- `useScriptDisplayNames` hook with stable refset fingerprint (no spurious
refetches on parent re-renders); clears stale map on tenant/refset change so
a session switch never leaks names across tenants.
- Chunked POST loop: 200-ref batches with partial-success semantics. Preserves
earlier chunks when later ones fail (non-2xx OR transport throw); only
TokenExpiredError propagates so auth flow can react.
- ScriptExecutions renders " . {DisplayName}" alongside existing label/short-id,
keyed by Kind + policyId so Platform/Remediation under the same id never
collide.
- New Settings section "Optional Graph capabilities": status table with granted
/ not granted / unknown, copy-paste PS one-liner with ClientId baked in,
refresh button gated on response.ok.
Customer-facing
- scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1: idempotent
grant / verify / revoke script. -Features ScriptDisplayNames (high level) or
-Permissions ... (admin-UI generated). Pure ASCII for PS 5.1 compat.
- docs/customer-graph-add-on-permissions.md: end-to-end customer guide
covering prerequisites, the appRoleAssignment pattern, and revocation.
Tests
- 1718 backend xUnit / 364 web vitest. Highlights: JWT roles parsing edge
cases; full-pull-429 suppresses per-ID; per-ID-429 breaks loop; transient
detection via GetSnapshotAsync; chunked partial success (HTTP failure + thrown
transport); TokenExpiredError propagation; EndpointPolicyCatalogCompleteness
covers the three new routes by reflection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
274 lines
10 KiB
C#
274 lines
10 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Net;
|
|
using System.Threading.Tasks;
|
|
using AutopilotMonitor.Functions.Services.GraphResolution;
|
|
using AutopilotMonitor.Shared.Models.Graph;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Xunit;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests.GraphResolution;
|
|
|
|
/// <summary>
|
|
/// End-to-end resolver behaviour: gate -> cache -> full-pull -> per-ID fallback -> 404 ->
|
|
/// transient. Uses fakes (no real Azure/Graph dependencies).
|
|
/// </summary>
|
|
public class ScriptDisplayNameResolverTests
|
|
{
|
|
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
|
|
|
[Fact]
|
|
public async Task Returns_all_null_when_permission_missing()
|
|
{
|
|
var fix = new Fixture();
|
|
// No context registered -> detector returns null.
|
|
var refs = new[] { new ScriptRef(ScriptKind.Platform, "abc") };
|
|
|
|
var result = await fix.Sut.ResolveAsync(TenantId, refs);
|
|
|
|
Assert.Single(result);
|
|
Assert.Null(result[refs[0]]);
|
|
Assert.Empty(fix.Http.Requests); // Graph never called when permission gate fails
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cache_hit_returns_displayName_without_graph_call()
|
|
{
|
|
var fix = new Fixture();
|
|
fix.GivenPermission();
|
|
var r = new ScriptRef(ScriptKind.Platform, "abc-123");
|
|
fix.Repo.Entries[(TenantId, r.Kind, r.Id)] = new ScriptDisplayNameEntry
|
|
{
|
|
TenantId = TenantId,
|
|
Kind = r.Kind,
|
|
ScriptId = r.Id,
|
|
DisplayName = "MyScript.ps1",
|
|
FetchedAt = fix.FixedNow,
|
|
IsNotFound = false,
|
|
};
|
|
// Meta row exists and is fresh -> no full-pull path.
|
|
fix.Repo.Meta[(TenantId, r.Kind)] = new ScriptNameCacheMeta
|
|
{
|
|
TenantId = TenantId,
|
|
Kind = r.Kind,
|
|
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
|
|
};
|
|
|
|
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
|
|
|
Assert.Equal("MyScript.ps1", result[r]);
|
|
Assert.Empty(fix.Http.Requests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cold_tenant_triggers_full_pull_and_populates_cache()
|
|
{
|
|
var fix = new Fixture();
|
|
fix.GivenPermission();
|
|
var r = new ScriptRef(ScriptKind.Platform, "id-1");
|
|
|
|
fix.Http.When("deviceManagementScripts?", HttpStatusCode.OK,
|
|
"{\"value\":[" +
|
|
"{\"id\":\"id-1\",\"displayName\":\"Bootstrap\",\"fileName\":\"bootstrap.ps1\"}," +
|
|
"{\"id\":\"id-2\",\"displayName\":\"Cleanup\",\"fileName\":\"cleanup.ps1\"}" +
|
|
"]}");
|
|
|
|
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
|
|
|
Assert.Equal("Bootstrap", result[r]);
|
|
Assert.Single(fix.Http.Requests);
|
|
// Both list entries cached for future calls.
|
|
Assert.Equal(2, fix.Repo.Entries.Count);
|
|
Assert.True(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NetNew_id_after_recent_full_pull_uses_per_id_fallback()
|
|
{
|
|
var fix = new Fixture();
|
|
fix.GivenPermission();
|
|
// Recent full-pull means we DON'T re-pull on a miss.
|
|
fix.Repo.Meta[(TenantId, ScriptKind.Remediation)] = new ScriptNameCacheMeta
|
|
{
|
|
TenantId = TenantId,
|
|
Kind = ScriptKind.Remediation,
|
|
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
|
|
};
|
|
var r = new ScriptRef(ScriptKind.Remediation, "net-new-id");
|
|
fix.Http.When("deviceHealthScripts/net-new-id", HttpStatusCode.OK,
|
|
"{\"id\":\"net-new-id\",\"displayName\":\"Net-New\"}");
|
|
|
|
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
|
|
|
Assert.Equal("Net-New", result[r]);
|
|
Assert.Single(fix.Http.Requests);
|
|
Assert.Contains("deviceHealthScripts/net-new-id", fix.Http.Requests[0]);
|
|
// Per-ID fetch also writes to cache.
|
|
Assert.True(fix.Repo.Entries.ContainsKey((TenantId, ScriptKind.Remediation, "net-new-id")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PerId_404_writes_negative_cache_returns_null()
|
|
{
|
|
var fix = new Fixture();
|
|
fix.GivenPermission();
|
|
fix.Repo.Meta[(TenantId, ScriptKind.Platform)] = new ScriptNameCacheMeta
|
|
{
|
|
TenantId = TenantId,
|
|
Kind = ScriptKind.Platform,
|
|
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
|
|
};
|
|
var r = new ScriptRef(ScriptKind.Platform, "deleted-id");
|
|
fix.Http.When("deviceManagementScripts/deleted-id", HttpStatusCode.NotFound, "{\"error\":{\"code\":\"NotFound\"}}");
|
|
|
|
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
|
|
|
Assert.Null(result[r]);
|
|
Assert.True(fix.Repo.Entries.ContainsKey((TenantId, ScriptKind.Platform, "deleted-id")));
|
|
Assert.True(fix.Repo.Entries[(TenantId, ScriptKind.Platform, "deleted-id")].IsNotFound);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Empty_refs_returns_empty_dict()
|
|
{
|
|
var fix = new Fixture();
|
|
fix.GivenPermission();
|
|
|
|
var result = await fix.Sut.ResolveAsync(TenantId, Array.Empty<ScriptRef>());
|
|
|
|
Assert.Empty(result);
|
|
Assert.Empty(fix.Http.Requests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Graph_5xx_during_full_pull_returns_null_without_throwing()
|
|
{
|
|
var fix = new Fixture();
|
|
fix.GivenPermission();
|
|
fix.Http.When("deviceManagementScripts?", HttpStatusCode.InternalServerError, "{}");
|
|
var r = new ScriptRef(ScriptKind.Platform, "abc");
|
|
|
|
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
|
|
|
Assert.Null(result[r]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FullPull_429_suppresses_per_id_fallback_for_same_kind()
|
|
{
|
|
// Finding 2: when full-pull is throttled (429), per-ID fallback would just
|
|
// amplify the throttle. We must abort per-ID for that kind.
|
|
var fix = new Fixture();
|
|
fix.GivenPermission();
|
|
fix.Http.When("deviceManagementScripts?", (HttpStatusCode)429, "{\"error\":{\"code\":\"TooManyRequests\"}}");
|
|
// Add the per-ID URL too — but the test asserts it should NOT be reached.
|
|
fix.Http.When("deviceManagementScripts/abc", HttpStatusCode.OK,
|
|
"{\"id\":\"abc\",\"displayName\":\"SHOULD-NOT-BE-RETURNED\"}");
|
|
var r = new ScriptRef(ScriptKind.Platform, "abc");
|
|
|
|
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
|
|
|
Assert.Null(result[r]); // per-ID never ran
|
|
Assert.Single(fix.Http.Requests); // only the failed full-pull
|
|
Assert.Contains("deviceManagementScripts?", fix.Http.Requests[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PerId_429_breaks_loop_on_first_transient()
|
|
{
|
|
// Finding 2: in the per-ID fallback path (after a recent full-pull), the
|
|
// first transient HTTP failure aborts the remaining per-ID calls so we
|
|
// don't dig the throttling hole deeper.
|
|
var fix = new Fixture();
|
|
fix.GivenPermission();
|
|
// Recent meta -> per-ID fallback path for net-new ids.
|
|
fix.Repo.Meta[(TenantId, ScriptKind.Remediation)] = new ScriptNameCacheMeta
|
|
{
|
|
TenantId = TenantId,
|
|
Kind = ScriptKind.Remediation,
|
|
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
|
|
};
|
|
|
|
fix.Http.When("deviceHealthScripts/first", (HttpStatusCode)429, "{}");
|
|
// Stub a happy second call to prove it would have succeeded if we kept going.
|
|
fix.Http.When("deviceHealthScripts/second", HttpStatusCode.OK,
|
|
"{\"id\":\"second\",\"displayName\":\"SHOULD-NOT-BE-RETURNED\"}");
|
|
|
|
var refs = new[]
|
|
{
|
|
new ScriptRef(ScriptKind.Remediation, "first"),
|
|
new ScriptRef(ScriptKind.Remediation, "second"),
|
|
};
|
|
|
|
var result = await fix.Sut.ResolveAsync(TenantId, refs);
|
|
|
|
Assert.All(refs, r => Assert.Null(result[r]));
|
|
Assert.Single(fix.Http.Requests); // second call never issued
|
|
Assert.Contains("deviceHealthScripts/first", fix.Http.Requests[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Negative_cache_hit_returns_null_no_graph_call()
|
|
{
|
|
var fix = new Fixture();
|
|
fix.GivenPermission();
|
|
var r = new ScriptRef(ScriptKind.Remediation, "deleted");
|
|
fix.Repo.Entries[(TenantId, r.Kind, r.Id)] = new ScriptDisplayNameEntry
|
|
{
|
|
TenantId = TenantId,
|
|
Kind = r.Kind,
|
|
ScriptId = r.Id,
|
|
DisplayName = null,
|
|
FetchedAt = fix.FixedNow - TimeSpan.FromMinutes(30),
|
|
IsNotFound = true,
|
|
};
|
|
|
|
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
|
|
|
Assert.Null(result[r]);
|
|
Assert.Empty(fix.Http.Requests);
|
|
}
|
|
|
|
// ── Fixture ──────────────────────────────────────────────────────────────
|
|
|
|
private sealed class Fixture
|
|
{
|
|
public FakeGraphFeatureDetector Detector { get; } = new();
|
|
public FakeScriptNameCacheRepository Repo { get; } = new();
|
|
public StubHttpMessageHandler Http { get; } = new();
|
|
public StubHttpClientFactory HttpFactory { get; }
|
|
public ScriptDisplayNameResolver Sut { get; }
|
|
public DateTimeOffset FixedNow { get; } = new(2026, 5, 18, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
public Fixture()
|
|
{
|
|
HttpFactory = new StubHttpClientFactory(Http);
|
|
Sut = new ScriptDisplayNameResolver(
|
|
Detector, Repo, HttpFactory,
|
|
NullLogger<ScriptDisplayNameResolver>.Instance,
|
|
new FakeTimeProvider(FixedNow));
|
|
}
|
|
|
|
public void GivenPermission()
|
|
{
|
|
Detector.ContextByTenant[TenantId] = new GraphTenantTokenContext
|
|
{
|
|
TenantId = TenantId,
|
|
AccessToken = "fake-token",
|
|
GrantedRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
GraphAppPermissions.DeviceManagementScriptsReadAll,
|
|
},
|
|
ExpiresAt = FixedNow.AddHours(1),
|
|
};
|
|
}
|
|
}
|
|
|
|
private sealed class FakeTimeProvider : TimeProvider
|
|
{
|
|
private readonly DateTimeOffset _now;
|
|
public FakeTimeProvider(DateTimeOffset now) => _now = now;
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
}
|
|
}
|