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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user