// ----------------------------------------------------------------------------- // PolicyAnalyzerRoslynTests.cs // Sprint: SPRINT_5100_0010_0004_airgap_tests // Tasks: AIRGAP-5100-005, AIRGAP-5100-006 // Description: AN1 Roslyn compilation tests for AirGap.Policy.Analyzers // ----------------------------------------------------------------------------- using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Net.Http; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Text; using Xunit; using FluentAssertions; using StellaOps.TestKit; namespace StellaOps.AirGap.Policy.Analyzers.Tests; /// /// AN1 Roslyn Compilation Tests for AirGap.Policy.Analyzers /// Task AIRGAP-5100-005: Expected diagnostics, no false positives /// Task AIRGAP-5100-006: Golden generated code tests for policy analyzers /// public sealed class PolicyAnalyzerRoslynTests { #region AIRGAP-5100-005: Expected Diagnostics & No False Positives [Trait("Category", TestCategories.Unit)] [Theory] [InlineData("var client = new HttpClient();", true, "Direct construction should trigger diagnostic")] [InlineData("var client = new System.Net.Http.HttpClient();", true, "Fully qualified construction should trigger diagnostic")] [InlineData("HttpClient client = new();", true, "Target-typed new should trigger diagnostic")] [InlineData("object client = new HttpClient();", true, "Implicit cast construction should trigger diagnostic")] public async Task DiagnosticTriggered_ForVariousHttpClientConstructions(string statement, bool shouldTrigger, string reason) { var source = $$""" using System.Net.Http; namespace Sample.App; public sealed class Demo { public void Run() { {{statement}} } } """; var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App"); var hasDiagnostic = diagnostics.Any(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId); hasDiagnostic.Should().Be(shouldTrigger, reason); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task NoDiagnostic_ForHttpClientParameter() { const string source = """ using System.Net.Http; namespace Sample.App; public sealed class Demo { public void Run(HttpClient client) { // Using HttpClient as parameter - not constructing it client.GetStringAsync("https://example.com"); } } """; var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App"); diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId, "Using HttpClient as parameter should not trigger diagnostic"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task NoDiagnostic_ForHttpClientField() { const string source = """ using System.Net.Http; namespace Sample.App; public sealed class Demo { private HttpClient? _client; public void SetClient(HttpClient client) { _client = client; } } """; var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App"); diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId, "Declaring HttpClient field should not trigger diagnostic"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task NoDiagnostic_ForFactoryMethodReturn() { const string source = """ using System.Net.Http; namespace Sample.App; public interface IHttpClientFactory { HttpClient CreateClient(string name); } public sealed class Demo { private readonly IHttpClientFactory _factory; public Demo(IHttpClientFactory factory) => _factory = factory; public void Run() { var client = _factory.CreateClient("default"); } } """; var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App"); diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId, "Using factory method should not trigger diagnostic"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task NoDiagnostic_InTestAssembly() { const string source = """ using System.Net.Http; namespace Sample.App.Tests; public sealed class DemoTests { public void TestMethod() { var client = new HttpClient(); } } """; var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App.Tests"); diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId, "Test assemblies should be exempt from diagnostic"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task NoDiagnostic_InPolicyAssembly() { const string source = """ using System.Net.Http; namespace StellaOps.AirGap.Policy.Internal; internal static class Loopback { public static HttpClient Create() => new HttpClient(); } """; var diagnostics = await AnalyzeAsync(source, assemblyName: "StellaOps.AirGap.Policy"); diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId, "Policy assembly itself should be exempt"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Diagnostic_HasCorrectSeverity() { const string source = """ using System.Net.Http; namespace Sample.App; public sealed class Demo { public void Run() { var client = new HttpClient(); } } """; var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App"); var airgapDiagnostic = diagnostics.Single(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId); airgapDiagnostic.Severity.Should().Be(DiagnosticSeverity.Warning, "Diagnostic should be a warning, not an error"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Diagnostic_HasCorrectLocation() { const string source = """ using System.Net.Http; namespace Sample.App; public sealed class Demo { public void Run() { var client = new HttpClient(); } } """; var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App"); var airgapDiagnostic = diagnostics.Single(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId); airgapDiagnostic.Location.IsInSource.Should().BeTrue(); var lineSpan = airgapDiagnostic.Location.GetLineSpan(); lineSpan.StartLinePosition.Line.Should().Be(8, "Diagnostic should point to line 9 (0-indexed: 8)"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task MultipleHttpClientUsages_ReportMultipleDiagnostics() { const string source = """ using System.Net.Http; namespace Sample.App; public sealed class Demo { public void Method1() { var client = new HttpClient(); } public void Method2() { var client = new HttpClient(); } public void Method3() { var client = new HttpClient(); } } """; var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App"); var airgapDiagnostics = diagnostics.Where(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId).ToList(); airgapDiagnostics.Should().HaveCount(3, "Each new HttpClient() should trigger a separate diagnostic"); } #endregion #region AIRGAP-5100-006: Golden Generated Code Tests [Trait("Category", TestCategories.Unit)] [Fact] public async Task Analyzer_SupportedDiagnostics_ContainsExpectedId() { var analyzer = new HttpClientUsageAnalyzer(); var supportedDiagnostics = analyzer.SupportedDiagnostics; supportedDiagnostics.Should().HaveCount(1); supportedDiagnostics[0].Id.Should().Be("AIRGAP001"); } #endregion #region Test Helpers private static async Task> AnalyzeAsync(string source, string assemblyName) { var compilation = CSharpCompilation.Create( assemblyName, new[] { CSharpSyntaxTree.ParseText(source), CSharpSyntaxTree.ParseText(PolicyStubSource), }, CreateMetadataReferences(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); var analyzer = new HttpClientUsageAnalyzer(); var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); } private static IEnumerable CreateMetadataReferences() { // Core runtime references yield return MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location); yield return MetadataReference.CreateFromFile(typeof(Uri).GetTypeInfo().Assembly.Location); yield return MetadataReference.CreateFromFile(typeof(HttpClient).GetTypeInfo().Assembly.Location); yield return MetadataReference.CreateFromFile(typeof(Enumerable).GetTypeInfo().Assembly.Location); // Add System.Runtime for target-typed new var systemRuntimePath = Path.GetDirectoryName(typeof(object).GetTypeInfo().Assembly.Location); if (!string.IsNullOrEmpty(systemRuntimePath)) { var netstandard = Path.Combine(systemRuntimePath, "netstandard.dll"); if (File.Exists(netstandard)) { yield return MetadataReference.CreateFromFile(netstandard); } var systemRuntime = Path.Combine(systemRuntimePath, "System.Runtime.dll"); if (File.Exists(systemRuntime)) { yield return MetadataReference.CreateFromFile(systemRuntime); } } } private const string PolicyStubSource = """ namespace StellaOps.AirGap.Policy { public interface IEgressPolicy { void EnsureAllowed(EgressRequest request); } public readonly record struct EgressRequest(string Component, System.Uri Destination, string Intent); public static class EgressHttpClientFactory { public static System.Net.Http.HttpClient Create(IEgressPolicy egressPolicy, EgressRequest request) => throw new System.NotImplementedException(); } } """; #endregion }