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 StellaOps.TestKit; namespace StellaOps.AirGap.Policy.Analyzers.Tests; public sealed class HttpClientUsageAnalyzerTests { [Trait("Category", TestCategories.Unit)] [Fact] public async Task ReportsDiagnostic_ForNewHttpClient() { 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"); Assert.Contains(diagnostics, d => d.Id == HttpClientUsageAnalyzer.DiagnosticId); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DoesNotReportDiagnostic_InsidePolicyAssembly() { 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"); Assert.DoesNotContain(diagnostics, d => d.Id == HttpClientUsageAnalyzer.DiagnosticId); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CodeFix_RewritesToFactoryCall() { const string source = """ using System.Net.Http; namespace Sample.Service; public sealed class Demo { public void Run() { var client = new HttpClient(); } } """; const string expected = """ using System.Net.Http; namespace Sample.Service; public sealed class Demo { public void Run() { var client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: /* TODO: provide IEgressPolicy instance */, request: new global::StellaOps.AirGap.Policy.EgressRequest(component: "REPLACE_COMPONENT", destination: new global::System.Uri("https://replace-with-endpoint"), intent: "REPLACE_INTENT")); } } """; var updated = await ApplyCodeFixAsync(source, assemblyName: "Sample.Service"); Assert.Equal(expected.ReplaceLineEndings(), updated.ReplaceLineEndings()); } 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 async Task ApplyCodeFixAsync(string source, string assemblyName) { using var workspace = new AdhocWorkspace(); var projectId = ProjectId.CreateNewId(); var documentId = DocumentId.CreateNewId(projectId); var stubDocumentId = DocumentId.CreateNewId(projectId); var solution = workspace.CurrentSolution .AddProject(projectId, "TestProject", "TestProject", LanguageNames.CSharp) .WithProjectCompilationOptions(projectId, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) .WithProjectAssemblyName(projectId, assemblyName) .AddMetadataReferences(projectId, CreateMetadataReferences()) .AddDocument(documentId, "Test.cs", SourceText.From(source)) .AddDocument(stubDocumentId, "PolicyStubs.cs", SourceText.From(PolicyStubSource)); var project = solution.GetProject(projectId)!; var document = solution.GetDocument(documentId)!; var compilation = await project.GetCompilationAsync(); var analyzer = new HttpClientUsageAnalyzer(); var diagnostics = await compilation!.WithAnalyzers(ImmutableArray.Create(analyzer)) .GetAnalyzerDiagnosticsAsync(); var diagnostic = Assert.Single(diagnostics); var codeFixProvider = new HttpClientUsageCodeFixProvider(); var actions = new List(); var context = new CodeFixContext( document, diagnostic, (action, _) => actions.Add(action), CancellationToken.None); await codeFixProvider.RegisterCodeFixesAsync(context); var action = Assert.Single(actions); var operations = await action.GetOperationsAsync(CancellationToken.None); foreach (var operation in operations) { operation.Apply(workspace, CancellationToken.None); } var updatedDocument = workspace.CurrentSolution.GetDocument(documentId)!; var updatedText = await updatedDocument.GetTextAsync(); return updatedText.ToString(); } private static IEnumerable CreateMetadataReferences() { 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); } 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(); } } """; }