// -----------------------------------------------------------------------------
// 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
}