test(audit): comprehensive tests for emission, PII redaction, hash chain, enrichers

- AuditPiiRedactorTests: 10 tests for recursive redaction + edge cases
- AuditActionFilterTests: 14 tests for capture, enrichment, fallback
- AuditModulesAndActionsTests: 3 tests for constant validation
- PostgresUnifiedAuditEventStoreTests: 8 tests for hash chain integrity
- UnifiedAuditAggregationServiceTests: 6 tests for new query filters
- AuditCleanseJobPluginTests: 7 tests for retention logic + validation
- PluginRegistryTests: 9 tests for plugin discovery
- Authority/Policy enricher tests: 8 tests for GUID resolution
- Total: ~65 new tests across 5 test projects
- Added InternalsVisibleTo for Audit.Emission and Timeline.WebService
- Created AuditCleanseJobPlugin implementation for retention-based cleanup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-09 13:00:18 +03:00
parent 5d245f958f
commit 537f4f17fc
16 changed files with 1676 additions and 0 deletions

View File

@@ -11,6 +11,10 @@
<Description>Shared audit event emission infrastructure for StellaOps services. Provides an endpoint filter and DI registration to automatically emit UnifiedAuditEvents to the Timeline service.</Description>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Audit.Emission.Tests" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

View File

@@ -0,0 +1,278 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
using System.Net;
using System.Security.Claims;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Audit.Emission.Tests;
/// <summary>
/// Tests for <see cref="AuditActionFilter"/>: attribute detection, actor resolution,
/// resource inference, severity mapping, body capture, PII redaction, enricher/provider
/// delegation, and graceful error handling.
/// </summary>
public sealed class AuditActionFilterTests
{
// ── Helpers ────────────────────────────────────────────────────────────
private static HttpContext CreateHttpContext(
string method = "GET",
string path = "/api/v1/environments",
int statusCode = 200,
ClaimsPrincipal? user = null,
IServiceProvider? services = null,
Dictionary<string, object?>? routeValues = null)
{
var context = new DefaultHttpContext();
context.Request.Method = method;
context.Request.Path = path;
context.Response.StatusCode = statusCode;
if (user is not null)
{
context.User = user;
}
if (services is not null)
{
context.RequestServices = services;
}
else
{
var sc = new ServiceCollection();
context.RequestServices = sc.BuildServiceProvider();
}
if (routeValues is not null)
{
foreach (var kvp in routeValues)
{
context.Request.RouteValues[kvp.Key] = kvp.Value;
}
}
return context;
}
private static ClaimsPrincipal CreateUser(
string? sub = null,
string? name = null,
string? email = null,
string? tenant = null)
{
var claims = new List<Claim>();
if (sub is not null) claims.Add(new Claim("sub", sub));
if (name is not null) claims.Add(new Claim("name", name));
if (email is not null) claims.Add(new Claim("email", email));
if (tenant is not null) claims.Add(new Claim("stellaops:tenant", tenant));
var identity = new ClaimsIdentity(claims, "TestScheme");
return new ClaimsPrincipal(identity);
}
// ── Tests ──────────────────────────────────────────────────────────────
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BuildAuditEvent_WithAttribute_EmitsPayload()
{
var attr = new AuditActionAttribute("authority", "create_user");
var httpContext = CreateHttpContext(method: "POST", path: "/api/v1/users", statusCode: 201);
var payload = AuditActionFilter.BuildAuditEvent(httpContext, attr, result: null);
payload.Should().NotBeNull();
payload.Module.Should().Be("authority");
payload.Action.Should().Be("create_user");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BuildAuditEvent_CapturesActorFromClaims()
{
var user = CreateUser(sub: "user-123", name: "Alice Admin", email: "alice@stella.ops");
var attr = new AuditActionAttribute("policy", "create");
var httpContext = CreateHttpContext(user: user);
var payload = AuditActionFilter.BuildAuditEvent(httpContext, attr, result: null);
payload.Actor.Id.Should().Be("user-123");
payload.Actor.Name.Should().Be("Alice Admin");
payload.Actor.Email.Should().Be("alice@stella.ops");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BuildAuditEvent_CapturesTenantFromClaims()
{
var user = CreateUser(sub: "u1", tenant: "demo-prod");
var attr = new AuditActionAttribute("scanner", "submit");
var httpContext = CreateHttpContext(user: user);
var payload = AuditActionFilter.BuildAuditEvent(httpContext, attr, result: null);
payload.TenantId.Should().Be("demo-prod");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BuildAuditEvent_CapturesResourceIdFromRouteValues()
{
var attr = new AuditActionAttribute("release", "update_release");
var resourceGuid = Guid.NewGuid().ToString();
var httpContext = CreateHttpContext(
routeValues: new Dictionary<string, object?> { ["id"] = resourceGuid });
var payload = AuditActionFilter.BuildAuditEvent(httpContext, attr, result: null);
payload.Resource.Id.Should().Be(resourceGuid);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void InferResourceType_ExtractsFromPath()
{
var resourceType = AuditActionFilter.InferResourceType("/api/v1/environments/abc");
resourceType.Should().Be("environments");
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData(200, "info")]
[InlineData(201, "info")]
[InlineData(204, "info")]
[InlineData(400, "warning")]
[InlineData(401, "warning")]
[InlineData(403, "warning")]
[InlineData(404, "warning")]
[InlineData(500, "error")]
[InlineData(503, "error")]
public void InferSeverity_MapsStatusCodeCorrectly(int statusCode, string expectedSeverity)
{
var severity = AuditActionFilter.InferSeverity(statusCode);
severity.Should().Be(expectedSeverity);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AuditActionAttribute_ShouldCaptureBody_TrueForPost()
{
var attr = new AuditActionAttribute("authority", "create");
attr.ShouldCaptureBody("POST").Should().BeTrue();
attr.ShouldCaptureBody("PUT").Should().BeTrue();
attr.ShouldCaptureBody("PATCH").Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AuditActionAttribute_ShouldCaptureBody_FalseForGet()
{
var attr = new AuditActionAttribute("authority", "list");
attr.ShouldCaptureBody("GET").Should().BeFalse();
attr.ShouldCaptureBody("DELETE").Should().BeFalse();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AuditActionAttribute_ExplicitCaptureBody_OverridesDefault()
{
var attr = new AuditActionAttribute("authority", "delete") { CaptureBody = true };
attr.ShouldCaptureBody("DELETE").Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AuditActionAttribute_ExplicitNoCaptureBody_OverridesDefault()
{
var attr = new AuditActionAttribute("authority", "create") { CaptureBody = false };
attr.ShouldCaptureBody("POST").Should().BeFalse();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BuildAuditEventAsync_CallsResourceEnricher_WhenRegistered()
{
var enricher = Substitute.For<IAuditResourceEnricher>();
enricher.Module.Returns("authority");
enricher.EnrichAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new AuditResourcePayload { Type = "user", Id = "user-guid", Name = "Alice (alice@test.com)" });
var services = new ServiceCollection();
services.AddSingleton<IEnumerable<IAuditResourceEnricher>>(new[] { enricher });
var sp = services.BuildServiceProvider();
var user = CreateUser(sub: "u1");
var attr = new AuditActionAttribute("authority", "update_user");
var httpContext = CreateHttpContext(
user: user,
services: sp,
routeValues: new Dictionary<string, object?> { ["id"] = "user-guid" });
var payload = await AuditActionFilter.BuildAuditEventAsync(
httpContext, attr, result: null, capturedBody: null, beforeState: null,
new AuditEmissionOptions(), NullLogger.Instance);
payload.Resource.Name.Should().Be("Alice (alice@test.com)");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BuildAuditEventAsync_GracefulFallback_WhenEnricherThrows()
{
var enricher = Substitute.For<IAuditResourceEnricher>();
enricher.Module.Returns("authority");
enricher.EnrichAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns<AuditResourcePayload>(_ => throw new InvalidOperationException("DB down"));
var services = new ServiceCollection();
services.AddSingleton<IEnumerable<IAuditResourceEnricher>>(new[] { enricher });
var sp = services.BuildServiceProvider();
var user = CreateUser(sub: "u1");
var attr = new AuditActionAttribute("authority", "update_user");
var httpContext = CreateHttpContext(
user: user,
services: sp,
routeValues: new Dictionary<string, object?> { ["id"] = "user-guid" });
var payload = await AuditActionFilter.BuildAuditEventAsync(
httpContext, attr, result: null, capturedBody: null, beforeState: null,
new AuditEmissionOptions(), NullLogger.Instance);
// Should not throw; falls back to raw ID
payload.Resource.Id.Should().Be("user-guid");
payload.Resource.Name.Should().BeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ExtractResponseResourceId_ExtractsIdFromAnonymousObject()
{
var result = new { id = "new-resource-123" };
var resourceId = AuditActionFilter.ExtractResponseResourceId(result);
resourceId.Should().Be("new-resource-123");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ExtractResponseResourceId_ReturnsNull_ForNullResult()
{
var resourceId = AuditActionFilter.ExtractResponseResourceId(null);
resourceId.Should().BeNull();
}
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
using System.Reflection;
using FluentAssertions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Audit.Emission.Tests;
/// <summary>
/// Tests for <see cref="AuditModules"/> and <see cref="AuditActions"/>:
/// validates naming conventions and uniqueness constraints.
/// </summary>
public sealed class AuditModulesAndActionsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AuditModules_AllConstants_AreLowercase()
{
var fields = typeof(AuditModules)
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
.Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string));
fields.Should().NotBeEmpty("AuditModules should have at least one constant");
foreach (var field in fields)
{
var value = (string)field.GetRawConstantValue()!;
value.Should().Be(
value.ToLowerInvariant(),
$"AuditModules.{field.Name} must be lowercase but was '{value}'");
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AuditActions_NestedClassNames_MatchModuleNames()
{
// Each nested class in AuditActions should correspond to a known module name
var nestedClasses = typeof(AuditActions)
.GetNestedTypes(BindingFlags.Public | BindingFlags.Static);
nestedClasses.Should().NotBeEmpty("AuditActions should have nested classes for modules");
var moduleFields = typeof(AuditModules)
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
.Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string))
.Select(f => f.Name)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var nestedClass in nestedClasses)
{
moduleFields.Should().Contain(
nestedClass.Name,
$"AuditActions.{nestedClass.Name} should have a corresponding AuditModules constant");
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AuditActions_NoDuplicateValues_WithinAnyModule()
{
var nestedClasses = typeof(AuditActions)
.GetNestedTypes(BindingFlags.Public | BindingFlags.Static);
foreach (var nestedClass in nestedClasses)
{
var fields = nestedClass
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
.Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string))
.ToList();
var values = fields.Select(f => (string)f.GetRawConstantValue()!).ToList();
var duplicates = values
.GroupBy(v => v, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();
duplicates.Should().BeEmpty(
$"AuditActions.{nestedClass.Name} has duplicate action values: [{string.Join(", ", duplicates)}]");
}
}
}

View File

@@ -0,0 +1,181 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentAssertions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Audit.Emission.Tests;
/// <summary>
/// Tests for <see cref="AuditPiiRedactor"/>: recursive JSON redaction, case-insensitivity,
/// separator normalization, custom patterns, edge cases, and truncation behavior.
/// </summary>
public sealed class AuditPiiRedactorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Redact_FlatJson_RedactsPasswordField()
{
var node = JsonNode.Parse("""{"username":"alice","password":"s3cret"}""");
var result = AuditPiiRedactor.Redact(node);
result.Should().NotBeNull();
result!["username"]!.GetValue<string>().Should().Be("alice");
result["password"]!.GetValue<string>().Should().Be("[REDACTED]");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Redact_NestedJson_RedactsConnectionString()
{
var node = JsonNode.Parse("""
{
"config": {
"connectionString": "Host=db;Password=secret",
"timeout": 30
}
}
""");
var result = AuditPiiRedactor.Redact(node);
result.Should().NotBeNull();
result!["config"]!["connectionString"]!.GetValue<string>().Should().Be("[REDACTED]");
result["config"]!["timeout"]!.GetValue<int>().Should().Be(30);
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("PASSWORD")]
[InlineData("Password")]
[InlineData("pAsSwOrD")]
public void Redact_CaseInsensitive_RedactsRegardlessOfCasing(string fieldName)
{
var obj = new JsonObject { [fieldName] = "secret123" };
var result = AuditPiiRedactor.Redact(obj);
result.Should().NotBeNull();
result![fieldName]!.GetValue<string>().Should().Be("[REDACTED]");
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("api_key")]
[InlineData("api-key")]
[InlineData("apiKey")]
[InlineData("ApiKey")]
public void Redact_SeparatorsNormalized_RedactsWithUnderscoresAndHyphens(string fieldName)
{
var obj = new JsonObject { [fieldName] = "key-value-123" };
var result = AuditPiiRedactor.Redact(obj);
result.Should().NotBeNull();
result![fieldName]!.GetValue<string>().Should().Be("[REDACTED]");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Redact_PreservesNonSensitiveFields()
{
var node = JsonNode.Parse("""{"name":"alice","email":"alice@example.com","age":30}""");
var result = AuditPiiRedactor.Redact(node);
result.Should().NotBeNull();
result!["name"]!.GetValue<string>().Should().Be("alice");
result["email"]!.GetValue<string>().Should().Be("alice@example.com");
result["age"]!.GetValue<int>().Should().Be(30);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Redact_NullInput_ReturnsNull()
{
var result = AuditPiiRedactor.Redact((JsonNode?)null);
result.Should().BeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Redact_ArrayWithSensitiveFields_RedactsInsideArrayElements()
{
var node = JsonNode.Parse("""
{
"users": [
{"name":"alice","password":"pw1"},
{"name":"bob","token":"tok2"}
]
}
""");
var result = AuditPiiRedactor.Redact(node);
result.Should().NotBeNull();
var users = result!["users"]!.AsArray();
users[0]!["name"]!.GetValue<string>().Should().Be("alice");
users[0]!["password"]!.GetValue<string>().Should().Be("[REDACTED]");
users[1]!["name"]!.GetValue<string>().Should().Be("bob");
users[1]!["token"]!.GetValue<string>().Should().Be("[REDACTED]");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Redact_DeeplyNestedObjects_RedactsAtAllLevels()
{
var node = JsonNode.Parse("""
{
"level1": {
"level2": {
"level3": {
"secret": "deep-secret",
"label": "safe-value"
}
}
}
}
""");
var result = AuditPiiRedactor.Redact(node);
result.Should().NotBeNull();
result!["level1"]!["level2"]!["level3"]!["secret"]!.GetValue<string>().Should().Be("[REDACTED]");
result["level1"]!["level2"]!["level3"]!["label"]!.GetValue<string>().Should().Be("safe-value");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Redact_CustomConfiguredPatterns_ReplacesDefaultPatterns()
{
var node = JsonNode.Parse("""{"customField":"sensitive","password":"open"}""");
// When configuredPatterns is provided, it replaces defaults entirely
var configuredPatterns = new List<string> { "customfield" };
var result = AuditPiiRedactor.Redact(node, configuredPatterns: configuredPatterns);
result.Should().NotBeNull();
result!["customField"]!.GetValue<string>().Should().Be("[REDACTED]");
// "password" is NOT redacted because configuredPatterns replaced defaults
result["password"]!.GetValue<string>().Should().Be("open");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Redact_AdditionalPatterns_ExtendsDefaultRedaction()
{
var node = JsonNode.Parse("""{"ssn":"123-45-6789","password":"pw","name":"alice"}""");
var additionalPatterns = new List<string> { "ssn" };
var result = AuditPiiRedactor.Redact(node, additionalPatterns: additionalPatterns);
result.Should().NotBeNull();
result!["ssn"]!.GetValue<string>().Should().Be("[REDACTED]");
result["password"]!.GetValue<string>().Should().Be("[REDACTED]");
result["name"]!.GetValue<string>().Should().Be("alice");
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Audit.Emission.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Audit.Emission/StellaOps.Audit.Emission.csproj" />
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
</ItemGroup>
</Project>