sprints work
This commit is contained in:
@@ -0,0 +1,557 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
public sealed class PolicyAnalyzerRoslynTests
|
||||
{
|
||||
#region AIRGAP-5100-005: Expected Diagnostics & No False Positives
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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)");
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
[Fact]
|
||||
public async Task CodeFix_GeneratesExpectedFactoryCall()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.Service;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
const string expectedGolden = """
|
||||
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 fixedCode = await ApplyCodeFixAsync(source, assemblyName: "Sample.Service");
|
||||
fixedCode.ReplaceLineEndings().Should().Be(expectedGolden.ReplaceLineEndings(),
|
||||
"Code fix should match golden output exactly");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CodeFix_PreservesTrivia()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.Service;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
// Important: this client handles external requests
|
||||
var client = new HttpClient(); // end of line comment
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var fixedCode = await ApplyCodeFixAsync(source, assemblyName: "Sample.Service");
|
||||
|
||||
// The code fix preserves the trivia from the original node
|
||||
fixedCode.Should().Contain("// Important: this client handles external requests",
|
||||
"Leading comment should be preserved");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CodeFix_DeterministicOutput()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.Determinism;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Apply code fix multiple times
|
||||
var result1 = await ApplyCodeFixAsync(source, assemblyName: "Sample.Determinism");
|
||||
var result2 = await ApplyCodeFixAsync(source, assemblyName: "Sample.Determinism");
|
||||
var result3 = await ApplyCodeFixAsync(source, assemblyName: "Sample.Determinism");
|
||||
|
||||
result1.Should().Be(result2, "Code fix should be deterministic");
|
||||
result2.Should().Be(result3, "Code fix should be deterministic");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CodeFix_ContainsRequiredPlaceholders()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.Service;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var fixedCode = await ApplyCodeFixAsync(source, assemblyName: "Sample.Service");
|
||||
|
||||
// Verify all required placeholders are present for developer to fill in
|
||||
fixedCode.Should().Contain("EgressHttpClientFactory.Create");
|
||||
fixedCode.Should().Contain("egressPolicy:");
|
||||
fixedCode.Should().Contain("IEgressPolicy");
|
||||
fixedCode.Should().Contain("EgressRequest");
|
||||
fixedCode.Should().Contain("component:");
|
||||
fixedCode.Should().Contain("REPLACE_COMPONENT");
|
||||
fixedCode.Should().Contain("destination:");
|
||||
fixedCode.Should().Contain("intent:");
|
||||
fixedCode.Should().Contain("REPLACE_INTENT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CodeFix_UsesFullyQualifiedNames()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.Service;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var fixedCode = await ApplyCodeFixAsync(source, assemblyName: "Sample.Service");
|
||||
|
||||
// Verify fully qualified names are used to avoid namespace conflicts
|
||||
fixedCode.Should().Contain("global::StellaOps.AirGap.Policy.EgressHttpClientFactory");
|
||||
fixedCode.Should().Contain("global::StellaOps.AirGap.Policy.EgressRequest");
|
||||
fixedCode.Should().Contain("global::System.Uri");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FixAllProvider_IsWellKnownBatchFixer()
|
||||
{
|
||||
var provider = new HttpClientUsageCodeFixProvider();
|
||||
var fixAllProvider = provider.GetFixAllProvider();
|
||||
|
||||
fixAllProvider.Should().Be(WellKnownFixAllProviders.BatchFixer,
|
||||
"Should use batch fixer for efficient multi-fix application");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CodeFixProvider_FixableDiagnosticIds_MatchesAnalyzer()
|
||||
{
|
||||
var analyzer = new HttpClientUsageAnalyzer();
|
||||
var codeFixProvider = new HttpClientUsageCodeFixProvider();
|
||||
|
||||
var analyzerIds = analyzer.SupportedDiagnostics.Select(d => d.Id).ToHashSet();
|
||||
var fixableIds = codeFixProvider.FixableDiagnosticIds.ToHashSet();
|
||||
|
||||
fixableIds.Should().BeSubsetOf(analyzerIds,
|
||||
"Code fix provider should only fix diagnostics reported by the analyzer");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static async Task<ImmutableArray<Diagnostic>> 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<DiagnosticAnalyzer>(analyzer));
|
||||
return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync();
|
||||
}
|
||||
|
||||
private static async Task<string> 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<DiagnosticAnalyzer>(analyzer))
|
||||
.GetAnalyzerDiagnosticsAsync();
|
||||
|
||||
var diagnostic = diagnostics.Single(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
|
||||
var codeFixProvider = new HttpClientUsageCodeFixProvider();
|
||||
var actions = new List<CodeAction>();
|
||||
var context = new CodeFixContext(
|
||||
document,
|
||||
diagnostic,
|
||||
(action, _) => actions.Add(action),
|
||||
CancellationToken.None);
|
||||
|
||||
await codeFixProvider.RegisterCodeFixesAsync(context);
|
||||
var action = actions.Single();
|
||||
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<MetadataReference> 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
|
||||
}
|
||||
Reference in New Issue
Block a user