using System; using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using AutopilotMonitor.Functions.Services.GraphResolution; using AutopilotMonitor.Shared.Models.Graph; using Microsoft.Extensions.Caching.Memory; 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); } }