sprints work

This commit is contained in:
StellaOps Bot
2025-12-24 16:28:46 +02:00
parent 8197588e74
commit 4231305fec
43 changed files with 7190 additions and 36 deletions

View File

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