using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.ApplicationInsights;
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace AutopilotMonitor.Functions.Tests.GraphResolution;
///
/// Pure-logic tests for . The cache +
/// token-service orchestration is covered indirectly by ScriptDisplayNameResolverTests
/// (PR-C), where a fake context is set up end-to-end.
///
public class GraphFeatureDetectorTests
{
[Fact]
public void TryParseToken_returns_single_role()
{
var token = BuildJwt(
new[] { new Claim("roles", GraphAppPermissions.DeviceManagementScriptsReadAll) },
expires: DateTime.UtcNow.AddHours(1));
Assert.True(GraphFeatureDetector.TryParseToken(token, out var roles, out var expiresAt));
Assert.Contains(GraphAppPermissions.DeviceManagementScriptsReadAll, roles);
Assert.Single(roles);
Assert.True(expiresAt > DateTimeOffset.UtcNow);
}
[Fact]
public void TryParseToken_collects_multiple_roles_case_insensitive()
{
var token = BuildJwt(
new[]
{
new Claim("roles", "DeviceManagementScripts.Read.All"),
new Claim("roles", "DeviceManagementConfiguration.Read.All"),
new Claim("roles", "DeviceManagementManagedDevices.Read.All"),
},
expires: DateTime.UtcNow.AddHours(1));
Assert.True(GraphFeatureDetector.TryParseToken(token, out var roles, out _));
Assert.Equal(3, roles.Count);
// Case-insensitive contains: tenant might issue with slightly different casing.
Assert.Contains("devicemanagementscripts.read.all", roles);
}
[Fact]
public void TryParseToken_returns_empty_set_when_roles_claim_missing()
{
var token = BuildJwt(
new[] { new Claim("scp", "User.Read") },
expires: DateTime.UtcNow.AddHours(1));
Assert.True(GraphFeatureDetector.TryParseToken(token, out var roles, out _));
Assert.Empty(roles);
}
[Fact]
public void TryParseToken_filters_empty_role_values()
{
var token = BuildJwt(
new[]
{
new Claim("roles", ""),
new Claim("roles", " "),
new Claim("roles", "DeviceManagementScripts.Read.All"),
},
expires: DateTime.UtcNow.AddHours(1));
Assert.True(GraphFeatureDetector.TryParseToken(token, out var roles, out _));
Assert.Single(roles);
Assert.Contains("DeviceManagementScripts.Read.All", roles);
}
[Fact]
public void TryParseToken_fails_on_garbage_input()
{
Assert.False(GraphFeatureDetector.TryParseToken("not.a.jwt", out _, out _));
Assert.False(GraphFeatureDetector.TryParseToken("", out _, out _));
Assert.False(GraphFeatureDetector.TryParseToken("definitely-not-base64-and-no-dots", out _, out _));
}
[Fact]
public void TryParseToken_reads_expiry_correctly()
{
var fixedExpiry = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc);
var token = BuildJwt(new[] { new Claim("roles", "x") }, expires: fixedExpiry);
Assert.True(GraphFeatureDetector.TryParseToken(token, out _, out var expiresAt));
// JWT 'exp' is unix seconds — second precision is sufficient.
Assert.Equal(fixedExpiry, expiresAt.UtcDateTime);
}
// ── HasPermission helper observable behaviour via cache-only path ────────
[Fact]
public void IsFeatureGranted_via_catalog()
{
var roles = new HashSet(StringComparer.OrdinalIgnoreCase)
{
GraphAppPermissions.DeviceManagementScriptsReadAll,
};
Assert.True(GraphFeatureCatalog.IsFeatureGranted(
GraphFeatureCatalog.FeatureScriptDisplayNames, roles));
Assert.False(GraphFeatureCatalog.IsFeatureGranted("UnknownFeature", roles));
}
[Fact]
public void IsFeatureGranted_returns_false_when_required_permission_missing()
{
var roles = new HashSet(StringComparer.OrdinalIgnoreCase) { "Foo.Bar" };
Assert.False(GraphFeatureCatalog.IsFeatureGranted(
GraphFeatureCatalog.FeatureScriptDisplayNames, roles));
}
[Fact]
public void IsFeatureGranted_accepts_any_collection_shape_without_unsafe_cast()
{
// Verifies Finding 3: the catalog API no longer demands ISet, so an
// IReadOnlySet (or any IEnumerable) works without casting.
IReadOnlySet roles = new HashSet(StringComparer.OrdinalIgnoreCase)
{
GraphAppPermissions.DeviceManagementScriptsReadAll,
};
Assert.True(GraphFeatureCatalog.IsFeatureGranted(
GraphFeatureCatalog.FeatureScriptDisplayNames, roles));
// Even a plain array (case-insensitive match handled internally).
string[] rolesArray = { "devicemanagementscripts.read.all" };
Assert.True(GraphFeatureCatalog.IsFeatureGranted(
GraphFeatureCatalog.FeatureScriptDisplayNames, rolesArray));
// Null collection is safely false.
Assert.False(GraphFeatureCatalog.IsFeatureGranted(
GraphFeatureCatalog.FeatureScriptDisplayNames, (IEnumerable?)null));
}
// ── ScriptRef parsing (Shared/Models/Graph) ─────────────────────────────
[Theory]
[InlineData("Platform:abc-123", ScriptKind.Platform, "abc-123")]
[InlineData("platform:abc-123", ScriptKind.Platform, "abc-123")]
[InlineData("Remediation:def", ScriptKind.Remediation, "def")]
[InlineData("REMEDIATION:def", ScriptKind.Remediation, "def")]
public void ScriptRef_parses_canonical_form(string input, ScriptKind kind, string id)
{
Assert.True(ScriptRef.TryParse(input, out var r));
Assert.Equal(kind, r.Kind);
Assert.Equal(id, r.Id);
}
[Theory]
[InlineData("")]
[InlineData(null)]
[InlineData("nokind")]
[InlineData("UnknownKind:abc")]
[InlineData(":id")]
[InlineData("Platform:")]
public void ScriptRef_rejects_malformed(string? input)
{
Assert.False(ScriptRef.TryParse(input, out _));
}
// ── Helpers ─────────────────────────────────────────────────────────────
private static string BuildJwt(IEnumerable claims, DateTime expires)
{
// JWT without signature is fine — the detector NEVER validates signature
// (this is our own token, read seconds after acquisition from Azure AD over TLS).
var jwt = new JwtSecurityToken(
issuer: "https://sts.windows.net/test",
audience: "https://graph.microsoft.com",
claims: claims,
notBefore: null,
expires: expires);
return new JwtSecurityTokenHandler().WriteToken(jwt);
}
/// Smoke: cache hit short-circuits — no token-service call needed.
[Fact]
public void Cache_hit_returns_cached_context_without_acquire()
{
var cache = new MemoryCache(new MemoryCacheOptions());
const string tenantId = "11111111-1111-1111-1111-111111111111";
var preSeeded = new GraphTenantTokenContext
{
TenantId = tenantId,
AccessToken = "fake",
GrantedRoles = new HashSet(StringComparer.OrdinalIgnoreCase)
{
GraphAppPermissions.DeviceManagementScriptsReadAll,
},
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
};
cache.Set($"graph-feature-detector:{tenantId}", preSeeded);
// We can verify cache layout works by reading back through the same key.
// Full Detector orchestration is covered in PR-C via the Resolver integration tests.
Assert.True(cache.TryGetValue($"graph-feature-detector:{tenantId}",
out GraphTenantTokenContext? roundtrip));
Assert.Equal(tenantId, roundtrip!.TenantId);
Assert.Contains(GraphAppPermissions.DeviceManagementScriptsReadAll, roundtrip.GrantedRoles);
}
// ── Negative caching: avoid replaying the AAD consent-propagation retry chain ─────
// for tenants where the multi-tenant app was never consented to (e.g. cross-tenant
// sessions a Global Admin keeps clicking through during agent triage).
[Fact]
public async Task PermanentFailure_caches_negative_verdict_and_short_circuits_next_call()
{
const string tenantId = "22222222-2222-2222-2222-222222222222";
var tokenService = new StubGraphTokenService();
var detector = NewDetector(tokenService, out var cache);
// First attempt: token service reports "no consent" definitively.
tokenService.NextResults.Enqueue(GraphTokenResult.PermanentFailure());
var first = await detector.TryGetTokenContextAsync(tenantId);
Assert.Null(first);
Assert.Equal(1, tokenService.CallCount);
// Cache now carries the negative marker (not visible as GraphTenantTokenContext).
Assert.True(cache.TryGetValue($"graph-feature-detector:{tenantId}", out object? cached));
Assert.NotNull(cached);
Assert.False(cached is GraphTenantTokenContext);
// Second attempt within the negative TTL: must NOT re-invoke the token service.
var second = await detector.TryGetTokenContextAsync(tenantId);
Assert.Null(second);
Assert.Equal(1, tokenService.CallCount);
}
[Fact]
public async Task PermanentFailure_negative_verdict_reports_not_transient_on_snapshot()
{
const string tenantId = "33333333-3333-3333-3333-333333333333";
var tokenService = new StubGraphTokenService();
var detector = NewDetector(tokenService, out _);
tokenService.NextResults.Enqueue(GraphTokenResult.PermanentFailure());
await detector.TryGetTokenContextAsync(tenantId); // primes negative cache
var snapshot = await detector.GetSnapshotAsync(tenantId);
// Verdict is definite — UI must NOT render "unknown / try again" on a negative cache
// hit (that's reserved for transient failures).
Assert.False(snapshot.IsTransient);
Assert.Empty(snapshot.GrantedRoles);
Assert.Equal(1, tokenService.CallCount);
}
[Fact]
public async Task TransientFailure_is_NOT_cached_and_retries_on_next_call()
{
const string tenantId = "44444444-4444-4444-4444-444444444444";
var tokenService = new StubGraphTokenService();
var detector = NewDetector(tokenService, out var cache);
tokenService.NextResults.Enqueue(GraphTokenResult.TransientFailure());
var first = await detector.TryGetTokenContextAsync(tenantId);
Assert.Null(first);
Assert.Equal(1, tokenService.CallCount);
// No entry persisted — transient failures must be retried.
Assert.False(cache.TryGetValue($"graph-feature-detector:{tenantId}", out object? _));
// Second attempt: now consent has propagated and the token comes back fine.
tokenService.NextResults.Enqueue(GraphTokenResult.Success(
BuildJwt(new[] { new Claim("roles", GraphAppPermissions.DeviceManagementScriptsReadAll) },
expires: DateTime.UtcNow.AddHours(1))));
var second = await detector.TryGetTokenContextAsync(tenantId);
Assert.NotNull(second);
Assert.Contains(GraphAppPermissions.DeviceManagementScriptsReadAll, second!.GrantedRoles);
Assert.Equal(2, tokenService.CallCount);
}
[Fact]
public async Task InvalidateTenant_clears_negative_marker_so_freshly_granted_tenant_lights_up()
{
const string tenantId = "55555555-5555-5555-5555-555555555555";
var tokenService = new StubGraphTokenService();
var detector = NewDetector(tokenService, out _);
// Step 1: tenant has not granted yet → negative cache.
tokenService.NextResults.Enqueue(GraphTokenResult.PermanentFailure());
await detector.TryGetTokenContextAsync(tenantId);
Assert.Equal(1, tokenService.CallCount);
// Step 2: admin runs the grant script then hits "Refresh" in the UI → InvalidateTenant.
detector.InvalidateTenant(tenantId);
// Step 3: next attempt MUST re-acquire (otherwise the customer would have to wait the
// full negative TTL despite explicitly asking for a fresh check).
tokenService.NextResults.Enqueue(GraphTokenResult.Success(
BuildJwt(new[] { new Claim("roles", GraphAppPermissions.DeviceManagementScriptsReadAll) },
expires: DateTime.UtcNow.AddHours(1))));
var refreshed = await detector.TryGetTokenContextAsync(tenantId);
Assert.NotNull(refreshed);
Assert.Contains(GraphAppPermissions.DeviceManagementScriptsReadAll, refreshed!.GrantedRoles);
Assert.Equal(2, tokenService.CallCount);
}
[Fact]
public async Task InvalidateTenant_drops_underlying_token_cache_so_post_grant_refresh_sees_new_roles()
{
// Regression for the exact consent-propagation bug: a token acquired BEFORE the grant
// carries an empty roles claim, and GraphTokenService caches that raw JWT (~55 min). After
// the admin grants the role and hits Refresh (→ InvalidateTenant), the detector must mint a
// FRESH token and report the new role — NOT re-parse the stale cached JWT. This exercises
// the cross-layer invalidation wiring with a REAL GraphTokenService (not the stub), so a
// future change that forgets to clear the token cache fails here.
const string tenantId = "66666666-6666-6666-6666-666666666666";
var handler = new SwappableTokenHandler(
BuildJwt(Array.Empty(), expires: DateTime.UtcNow.AddHours(1))); // pre-grant: no roles
var cache = new MemoryCache(new MemoryCacheOptions());
var tokenConfig = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary
{
["EntraId:ClientId"] = "test-client",
["EntraId:ClientSecret"] = "test-secret",
})
.Build();
var tokenService = new GraphTokenService(
NullLogger.Instance,
new SingleHandlerFactory(handler),
cache,
tokenConfig,
new EntraAppRegistry(tokenConfig, NullLogger.Instance),
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
Moq.Mock.Of(),
NullLogger.Instance,
new MemoryCache(new MemoryCacheOptions())));
var telemetry = new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true });
var detector = new GraphFeatureDetector(
tokenService, cache, NullLogger.Instance, telemetry);
// Pre-grant read: empty roles, and the raw JWT is now cached in GraphTokenService.
var before = await detector.GetSnapshotAsync(tenantId);
Assert.Empty(before.GrantedRoles);
Assert.Equal(1, handler.Requests);
// Admin grants the role; the wire now returns a JWT WITH it.
handler.Body = BuildJwt(
new[] { new Claim("roles", GraphAppPermissions.DeviceManagementScriptsReadAll) },
expires: DateTime.UtcNow.AddHours(1));
// The fresh-read trigger. Without the cross-layer wiring the stale empty-roles JWT would
// still be served and this would keep reporting "not granted".
detector.InvalidateTenant(tenantId);
var after = await detector.GetSnapshotAsync(tenantId);
Assert.Contains(GraphAppPermissions.DeviceManagementScriptsReadAll, after.GrantedRoles);
Assert.Equal(2, handler.Requests); // fresh token minted, not served from cache
}
[Fact]
public async Task InvalidateTenant_during_in_flight_acquire_does_not_cache_stale_roles()
{
// Write-after-invalidate race on the DETECTOR's own cache: an acquire that started before
// InvalidateTenant must not leave a stale parsed-roles ctx behind. The token acquire is
// parked in flight; the admin grants + Refreshes (InvalidateTenant) while it is parked; on
// release the detector writes a (stale-generation-tagged) ctx. A fresh read MUST reject it
// and re-acquire, seeing the newly granted role.
const string tenantId = "77777777-7777-7777-7777-777777777777";
var tokenService = new GatedStubGraphTokenService(
firstToken: BuildJwt(Array.Empty(), expires: DateTime.UtcNow.AddHours(1)), // pre-grant: no roles
laterToken: BuildJwt(new[] { new Claim("roles", GraphAppPermissions.DeviceManagementScriptsReadAll) },
expires: DateTime.UtcNow.AddHours(1)));
var cache = new MemoryCache(new MemoryCacheOptions());
var telemetry = new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true });
var detector = new GraphFeatureDetector(
tokenService, cache, NullLogger.Instance, telemetry);
// D1 starts; its token acquire parks in flight (genAtStart captured at method entry).
var inflight = detector.GetSnapshotAsync(tenantId);
await WaitUntilAsync(() => tokenService.CallCount >= 1, TimeSpan.FromSeconds(2));
// Admin grants + Refresh WHILE D1 is mid-acquire.
detector.InvalidateTenant(tenantId);
// D1 completes → writes a CachedVerdict tagged with the pre-invalidation generation.
tokenService.Release();
var before = await inflight;
Assert.Empty(before.GrantedRoles); // D1 itself saw the pre-grant token
// Fresh read must reject the stale-tagged entry (gen mismatch) and re-acquire → new role.
var after = await detector.GetSnapshotAsync(tenantId);
Assert.Contains(GraphAppPermissions.DeviceManagementScriptsReadAll, after.GrantedRoles);
Assert.Equal(2, tokenService.CallCount); // a real re-acquire happened (no stale cache hit)
}
[Fact]
public async Task InvalidateTenant_token_layer_first_prevents_stale_detector_write_in_overlap()
{
// Cross-layer ordering race: InvalidateTenant must invalidate the token layer BEFORE bumping
// its own generation. We pause INSIDE the token-layer invalidation and slip a concurrent
// GetSnapshotAsync into the gap. With the correct (token-first) order, that overlapping read
// cannot leave an accepted stale ctx behind; with the buggy (gen-first) order it would, and
// the post-refresh read below would still report the old (no-roles) verdict.
const string tenantId = "88888888-8888-8888-8888-888888888888";
var handler = new SwappableTokenHandler(
BuildJwt(Array.Empty(), expires: DateTime.UtcNow.AddHours(1))); // pre-grant: no roles
var cache = new MemoryCache(new MemoryCacheOptions());
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var release = new System.Threading.ManualResetEventSlim(false);
var tokenService = new BarrieredInvalidateTokenService(
new SingleHandlerFactory(handler), cache,
onInvalidate: () => { entered.TrySetResult(); release.Wait(TimeSpan.FromSeconds(5)); });
var telemetry = new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true });
var detector = new GraphFeatureDetector(
tokenService, cache, NullLogger.Instance, telemetry);
// Prime: cache the old (no-roles) token + parsed ctx on both layers.
var primed = await detector.GetSnapshotAsync(tenantId);
Assert.Empty(primed.GrantedRoles);
// Admin grants the role on the wire.
handler.Body = BuildJwt(
new[] { new Claim("roles", GraphAppPermissions.DeviceManagementScriptsReadAll) },
expires: DateTime.UtcNow.AddHours(1));
// Admin Refresh → InvalidateTenant, blocked inside the token-layer invalidation step.
var invalidateTask = Task.Run(() => detector.InvalidateTenant(tenantId));
await entered.Task;
// A concurrent read slips into the gap, then we let InvalidateTenant finish.
var overlap = await detector.GetSnapshotAsync(tenantId);
release.Set();
await invalidateTask;
// Post-refresh read MUST reflect the granted role — no stale entry survived the overlap.
var afterRefresh = await detector.GetSnapshotAsync(tenantId);
Assert.Contains(GraphAppPermissions.DeviceManagementScriptsReadAll, afterRefresh.GrantedRoles);
_ = overlap; // the overlapping read itself may legitimately observe pre-refresh state
}
private static async Task WaitUntilAsync(Func condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
{
await Task.Delay(10);
}
}
// ── Test seam: token service stub + detector factory ──────────────────────────────
/// Minimal EntraId config for the GraphTokenService base ctor (no legacy app).
private static Microsoft.Extensions.Configuration.IConfiguration StubEntraConfig() =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary
{
["EntraId:ClientId"] = "test-client-id",
["EntraId:ClientSecret"] = "test-secret",
})
.Build();
///
/// Dual app-reg dependencies for the GraphTokenService base ctor. No legacy app configured
/// ⇒ the registry always resolves primary and the tenant-config lookup is never consulted.
///
private static EntraAppRegistry StubAppRegistry() =>
new(StubEntraConfig(), NullLogger.Instance);
private static AutopilotMonitor.Functions.Services.TenantConfigurationService StubTenantConfigService() =>
new(Moq.Mock.Of(),
NullLogger.Instance,
new MemoryCache(new MemoryCacheOptions()));
///
/// Subclass that bypasses the real AAD HTTP call so tests can drive the detector with
/// canned values. Base ctor dependencies are satisfied
/// but never touched (we override the only method the detector calls).
///
private sealed class StubGraphTokenService : GraphTokenService
{
public Queue NextResults { get; } = new();
public int CallCount { get; private set; }
public StubGraphTokenService()
: base(
NullLogger.Instance,
new NoopHttpClientFactory(),
new MemoryCache(new MemoryCacheOptions()),
StubEntraConfig(),
StubAppRegistry(),
StubTenantConfigService())
{
}
public override Task GetAccessTokenAsync(string tenantId, CancellationToken ct = default)
{
CallCount++;
return Task.FromResult(NextResults.Count > 0
? NextResults.Dequeue()
: GraphTokenResult.PermanentFailure());
}
}
///
/// Token-service stub whose FIRST acquire parks until is called (modelling
/// a token fetch in flight while an InvalidateTenant runs), returning firstToken; every
/// later acquire returns laterToken immediately. Drives the detector's in-flight
/// write-after-invalidate test. CallCount is incremented BEFORE parking and read thread-safely.
///
private sealed class GatedStubGraphTokenService : GraphTokenService
{
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly string _firstToken;
private readonly string _laterToken;
private int _calls;
public GatedStubGraphTokenService(string firstToken, string laterToken)
: base(
NullLogger.Instance,
new NoopHttpClientFactory(),
new MemoryCache(new MemoryCacheOptions()),
StubEntraConfig(),
StubAppRegistry(),
StubTenantConfigService())
{
_firstToken = firstToken;
_laterToken = laterToken;
}
public int CallCount => System.Threading.Volatile.Read(ref _calls);
public void Release() => _release.TrySetResult();
public override async Task GetAccessTokenAsync(string tenantId, CancellationToken ct = default)
{
var n = System.Threading.Interlocked.Increment(ref _calls);
if (n == 1)
{
await _release.Task.ConfigureAwait(false);
return GraphTokenResult.Success(_firstToken);
}
return GraphTokenResult.Success(_laterToken);
}
}
///
/// Real GraphTokenService (real token cache + generation + HTTP path) whose InvalidateTenant is
/// wrapped with a test-controlled barrier, run BEFORE delegating to the base. Lets a test pause
/// inside the token-layer invalidation and interleave a concurrent acquire, to prove the
/// cross-layer ordering in GraphFeatureDetector.InvalidateTenant.
///
private sealed class BarrieredInvalidateTokenService : GraphTokenService
{
private readonly Action _onInvalidate;
public BarrieredInvalidateTokenService(
System.Net.Http.IHttpClientFactory factory, IMemoryCache cache, Action onInvalidate)
: base(
NullLogger.Instance,
factory,
cache,
StubEntraConfig(),
StubAppRegistry(),
StubTenantConfigService())
{
_onInvalidate = onInvalidate;
}
public override void InvalidateTenant(string tenantId)
{
_onInvalidate();
base.InvalidateTenant(tenantId);
}
}
/// Placeholder factory — the detector never causes the base GraphTokenService HTTP path to run.
private sealed class NoopHttpClientFactory : System.Net.Http.IHttpClientFactory
{
public System.Net.Http.HttpClient CreateClient(string name) =>
throw new InvalidOperationException("HTTP path should not be exercised in detector tests");
}
private static GraphFeatureDetector NewDetector(StubGraphTokenService tokenService, out IMemoryCache cache)
{
cache = new MemoryCache(new MemoryCacheOptions());
var telemetry = new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true });
return new GraphFeatureDetector(
tokenService,
cache,
NullLogger.Instance,
telemetry);
}
}