save progress
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Replay.Anonymization\StellaOps.Replay.Anonymization.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,477 @@
|
||||
// <copyright file="TraceAnonymizerTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_002_TEST_trace_replay_evidence
|
||||
// Task: TREP-001, TREP-002
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Anonymization.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TraceAnonymizerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly TraceAnonymizer _anonymizer;
|
||||
|
||||
public TraceAnonymizerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 5, 12, 0, 0, TimeSpan.Zero));
|
||||
_anonymizer = new TraceAnonymizer(
|
||||
NullLogger<TraceAnonymizer>.Instance,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymizeAsync_RedactsIpAddresses_WhenEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
|
||||
{
|
||||
["client_ip"] = "192.168.1.100",
|
||||
["server_ip"] = "10.0.0.1",
|
||||
["message"] = "Connected from 172.16.0.1 to server"
|
||||
});
|
||||
var options = AnonymizationOptions.Default with { RedactIpAddresses = true };
|
||||
|
||||
// Act
|
||||
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
var span = result.Spans.Single();
|
||||
span.Attributes["client_ip"].Should().Be("[REDACTED_IP]");
|
||||
span.Attributes["server_ip"].Should().Be("[REDACTED_IP]");
|
||||
span.Attributes["message"].Should().Contain("[REDACTED_IP]");
|
||||
result.Manifest.RedactionCategories.Should().Contain("ip_address");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymizeAsync_RedactsEmails_Automatically()
|
||||
{
|
||||
// Arrange
|
||||
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
|
||||
{
|
||||
["contact"] = "admin@example.com",
|
||||
["message"] = "Sent notification to user@domain.org"
|
||||
});
|
||||
var options = AnonymizationOptions.Default;
|
||||
|
||||
// Act
|
||||
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
var span = result.Spans.Single();
|
||||
span.Attributes["contact"].Should().Be("[REDACTED_EMAIL]");
|
||||
span.Attributes["message"].Should().Contain("[REDACTED_EMAIL]");
|
||||
result.Manifest.RedactionCategories.Should().Contain("email");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymizeAsync_RedactsUserIds_WhenEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
|
||||
{
|
||||
["user_id"] = "user-12345",
|
||||
["owner"] = "jsmith",
|
||||
["author_name"] = "John Doe",
|
||||
["regular_field"] = "not a user id"
|
||||
});
|
||||
var options = AnonymizationOptions.Default with { RedactUserIds = true };
|
||||
|
||||
// Act
|
||||
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
var span = result.Spans.Single();
|
||||
span.Attributes["user_id"].Should().Be("[REDACTED_USER_ID]");
|
||||
span.Attributes["owner"].Should().Be("[REDACTED_USER_ID]");
|
||||
span.Attributes["author_name"].Should().Be("[REDACTED_USER_ID]");
|
||||
span.Attributes["regular_field"].Should().Be("not a user id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymizeAsync_AnonymizesFilePaths_WhenEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
|
||||
{
|
||||
["file_path"] = "/home/jsmith/projects/secret/config.yaml",
|
||||
["windows_path"] = "C:\\Users\\admin\\Documents\\report.pdf"
|
||||
});
|
||||
var options = AnonymizationOptions.Default with { RedactFilePaths = true };
|
||||
|
||||
// Act
|
||||
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
var span = result.Spans.Single();
|
||||
span.Attributes["file_path"].Should().Contain("[DIR]").And.EndWith("config.yaml");
|
||||
span.Attributes["windows_path"].Should().Contain("[DIR]").And.EndWith("report.pdf");
|
||||
result.Manifest.RedactionCategories.Should().Contain("file_path");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymizeAsync_AnonymizesImageNames_WhenEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
|
||||
{
|
||||
["image_ref"] = "registry.example.com/team/myapp:v1.2.3",
|
||||
["container_image"] = "ghcr.io/org/service:latest"
|
||||
});
|
||||
var options = AnonymizationOptions.Default with { RedactImageNames = true };
|
||||
|
||||
// Act
|
||||
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
var span = result.Spans.Single();
|
||||
span.Attributes["image_ref"].Should().Be("[REGISTRY]/[REPO]:v1.2.3");
|
||||
span.Attributes["container_image"].Should().Be("[REGISTRY]/[REPO]:latest");
|
||||
result.Manifest.RedactionCategories.Should().Contain("image_name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymizeAsync_RedactsEnvironmentVariables_WhenEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
|
||||
{
|
||||
["env_var"] = "DATABASE_URL=postgres://secret@host/db",
|
||||
["PATH"] = "/usr/local/bin:/home/user/bin"
|
||||
});
|
||||
var options = AnonymizationOptions.Default with { RedactEnvironmentVariables = true };
|
||||
|
||||
// Act
|
||||
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
var span = result.Spans.Single();
|
||||
span.Attributes["env_var"].Should().Be("[REDACTED_ENV]");
|
||||
span.Attributes["PATH"].Should().Be("[REDACTED_ENV]");
|
||||
result.Manifest.RedactionCategories.Should().Contain("env_var");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymizeAsync_PreservesAllowlistedValues()
|
||||
{
|
||||
// Arrange
|
||||
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
|
||||
{
|
||||
["ip"] = "127.0.0.1",
|
||||
["other_ip"] = "192.168.1.1"
|
||||
});
|
||||
var options = AnonymizationOptions.Default with
|
||||
{
|
||||
RedactIpAddresses = true,
|
||||
AllowlistedValues = ["127.0.0.1"]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
var span = result.Spans.Single();
|
||||
span.Attributes["ip"].Should().Be("127.0.0.1");
|
||||
span.Attributes["other_ip"].Should().Be("[REDACTED_IP]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymizeAsync_AppliesCustomPatterns()
|
||||
{
|
||||
// Arrange
|
||||
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
|
||||
{
|
||||
["secret_key"] = "sk_live_abc123xyz789",
|
||||
["api_key"] = "api-key-secret-12345"
|
||||
});
|
||||
var options = AnonymizationOptions.Default with
|
||||
{
|
||||
AdditionalPiiPatterns = ["sk_live_\\w+", "api-key-\\w+-\\d+"]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
var span = result.Spans.Single();
|
||||
span.Attributes["secret_key"].Should().Be("[REDACTED]");
|
||||
span.Attributes["api_key"].Should().Be("[REDACTED]");
|
||||
result.Manifest.RedactionCategories.Should().Contain("custom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymizeAsync_GeneratesDeterministicTraceId()
|
||||
{
|
||||
// Arrange
|
||||
var trace = CreateSimpleTrace("original-trace-id-123");
|
||||
var options = AnonymizationOptions.Default;
|
||||
|
||||
// Act
|
||||
var result1 = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
|
||||
var result2 = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result1.TraceId.Should().Be(result2.TraceId);
|
||||
result1.TraceId.Should().StartWith("anon-");
|
||||
result1.TraceId.Should().NotBe(trace.TraceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymizeAsync_HashesSpanIds()
|
||||
{
|
||||
// Arrange
|
||||
var trace = CreateSimpleTrace("test-trace");
|
||||
var options = AnonymizationOptions.Default;
|
||||
|
||||
// Act
|
||||
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
var span = result.Spans.Single();
|
||||
span.SpanId.Should().HaveLength(16);
|
||||
span.SpanId.Should().NotBe(trace.Spans[0].SpanId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymizeAsync_PreservesStructuralIntegrity()
|
||||
{
|
||||
// Arrange
|
||||
var originalSpan = new TraceSpan(
|
||||
SpanId: "span-1",
|
||||
ParentSpanId: "parent-span",
|
||||
OperationName: "ProcessRequest",
|
||||
StartTime: _timeProvider.GetUtcNow(),
|
||||
Duration: TimeSpan.FromMilliseconds(150),
|
||||
Attributes: new Dictionary<string, string>
|
||||
{
|
||||
["status"] = "ok",
|
||||
["count"] = "42"
|
||||
}.ToImmutableDictionary(),
|
||||
Events: [
|
||||
new SpanEvent(
|
||||
Name: "checkpoint",
|
||||
Timestamp: _timeProvider.GetUtcNow().AddMilliseconds(50),
|
||||
Attributes: new Dictionary<string, string>
|
||||
{
|
||||
["event_data"] = "data"
|
||||
}.ToImmutableDictionary())
|
||||
]);
|
||||
var trace = new ProductionTrace(
|
||||
TraceId: "trace-123",
|
||||
CapturedAt: _timeProvider.GetUtcNow().AddDays(-1),
|
||||
Type: TraceType.Scan,
|
||||
Spans: [originalSpan],
|
||||
TotalDuration: TimeSpan.FromMilliseconds(150));
|
||||
var options = AnonymizationOptions.Default;
|
||||
|
||||
// Act
|
||||
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Spans.Should().HaveCount(1);
|
||||
var span = result.Spans[0];
|
||||
span.OperationName.Should().Be("ProcessRequest");
|
||||
span.Duration.Should().Be(TimeSpan.FromMilliseconds(150));
|
||||
span.Events.Should().HaveCount(1);
|
||||
span.Events[0].Name.Should().Be("checkpoint");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymizeAsync_RecordsAnonymizationManifest()
|
||||
{
|
||||
// Arrange
|
||||
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
|
||||
{
|
||||
["ip"] = "192.168.1.1",
|
||||
["email"] = "test@example.com",
|
||||
["normal"] = "value"
|
||||
});
|
||||
var options = AnonymizationOptions.Default with { RedactIpAddresses = true };
|
||||
|
||||
// Act
|
||||
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Manifest.TotalFieldsProcessed.Should().Be(3);
|
||||
result.Manifest.FieldsRedacted.Should().Be(2);
|
||||
result.Manifest.FieldsPreserved.Should().Be(1);
|
||||
result.Manifest.AnonymizationVersion.Should().Be("1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAnonymizationAsync_DetectsPiiViolations()
|
||||
{
|
||||
// Arrange
|
||||
var leakyTrace = new AnonymizedTrace(
|
||||
TraceId: "anon-test",
|
||||
OriginalTraceIdHash: "hash",
|
||||
CapturedAt: _timeProvider.GetUtcNow(),
|
||||
AnonymizedAt: _timeProvider.GetUtcNow(),
|
||||
Type: TraceType.Scan,
|
||||
Spans: [
|
||||
new AnonymizedSpan(
|
||||
SpanId: "span1",
|
||||
ParentSpanId: null,
|
||||
OperationName: "test",
|
||||
StartTime: _timeProvider.GetUtcNow(),
|
||||
Duration: TimeSpan.FromSeconds(1),
|
||||
Attributes: new Dictionary<string, string>
|
||||
{
|
||||
["leaked_ip"] = "192.168.1.100",
|
||||
["leaked_email"] = "user@example.com"
|
||||
}.ToImmutableDictionary(),
|
||||
Events: [])
|
||||
],
|
||||
Manifest: new AnonymizationManifest(0, 0, 0, [], "1.0.0"),
|
||||
TotalDuration: TimeSpan.FromSeconds(1));
|
||||
|
||||
// Act
|
||||
var result = await _anonymizer.ValidateAnonymizationAsync(leakyTrace, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Violations.Should().HaveCount(2);
|
||||
result.Violations.Should().Contain(v => v.ViolationType == PiiType.IpAddress);
|
||||
result.Violations.Should().Contain(v => v.ViolationType == PiiType.Email);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAnonymizationAsync_PassesForCleanTrace()
|
||||
{
|
||||
// Arrange
|
||||
var cleanTrace = new AnonymizedTrace(
|
||||
TraceId: "anon-test",
|
||||
OriginalTraceIdHash: "hash",
|
||||
CapturedAt: _timeProvider.GetUtcNow(),
|
||||
AnonymizedAt: _timeProvider.GetUtcNow(),
|
||||
Type: TraceType.Scan,
|
||||
Spans: [
|
||||
new AnonymizedSpan(
|
||||
SpanId: "span1",
|
||||
ParentSpanId: null,
|
||||
OperationName: "test",
|
||||
StartTime: _timeProvider.GetUtcNow(),
|
||||
Duration: TimeSpan.FromSeconds(1),
|
||||
Attributes: new Dictionary<string, string>
|
||||
{
|
||||
["status"] = "ok",
|
||||
["count"] = "42"
|
||||
}.ToImmutableDictionary(),
|
||||
Events: [])
|
||||
],
|
||||
Manifest: new AnonymizationManifest(2, 0, 2, [], "1.0.0"),
|
||||
TotalDuration: TimeSpan.FromSeconds(1));
|
||||
|
||||
// Act
|
||||
var result = await _anonymizer.ValidateAnonymizationAsync(cleanTrace, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Violations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymizeAsync_RespectsCancellation()
|
||||
{
|
||||
// Arrange
|
||||
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
|
||||
{
|
||||
["field"] = "value"
|
||||
});
|
||||
var options = AnonymizationOptions.Default;
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
||||
await _anonymizer.AnonymizeAsync(trace, options, cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymizeAsync_PreservesTraceType()
|
||||
{
|
||||
// Arrange
|
||||
var trace = CreateSimpleTrace("test", TraceType.VexConsensus);
|
||||
var options = AnonymizationOptions.Default;
|
||||
|
||||
// Act
|
||||
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Type.Should().Be(TraceType.VexConsensus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymizeAsync_PreservesDurations()
|
||||
{
|
||||
// Arrange
|
||||
var originalDuration = TimeSpan.FromMinutes(5);
|
||||
var trace = new ProductionTrace(
|
||||
TraceId: "test",
|
||||
CapturedAt: _timeProvider.GetUtcNow(),
|
||||
Type: TraceType.Scan,
|
||||
Spans: [
|
||||
new TraceSpan(
|
||||
SpanId: "span1",
|
||||
ParentSpanId: null,
|
||||
OperationName: "op",
|
||||
StartTime: _timeProvider.GetUtcNow(),
|
||||
Duration: TimeSpan.FromMinutes(2),
|
||||
Attributes: ImmutableDictionary<string, string>.Empty,
|
||||
Events: [])
|
||||
],
|
||||
TotalDuration: originalDuration);
|
||||
var options = AnonymizationOptions.Default;
|
||||
|
||||
// Act
|
||||
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.TotalDuration.Should().Be(originalDuration);
|
||||
result.Spans[0].Duration.Should().Be(TimeSpan.FromMinutes(2));
|
||||
}
|
||||
|
||||
private ProductionTrace CreateSimpleTrace(string traceId, TraceType type = TraceType.Scan)
|
||||
{
|
||||
return new ProductionTrace(
|
||||
TraceId: traceId,
|
||||
CapturedAt: _timeProvider.GetUtcNow(),
|
||||
Type: type,
|
||||
Spans: [
|
||||
new TraceSpan(
|
||||
SpanId: "span-1",
|
||||
ParentSpanId: null,
|
||||
OperationName: "TestOperation",
|
||||
StartTime: _timeProvider.GetUtcNow(),
|
||||
Duration: TimeSpan.FromMilliseconds(100),
|
||||
Attributes: ImmutableDictionary<string, string>.Empty,
|
||||
Events: [])
|
||||
],
|
||||
TotalDuration: TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
|
||||
private ProductionTrace CreateTraceWithAttributes(Dictionary<string, string> attributes)
|
||||
{
|
||||
return new ProductionTrace(
|
||||
TraceId: "test-trace",
|
||||
CapturedAt: _timeProvider.GetUtcNow(),
|
||||
Type: TraceType.Scan,
|
||||
Spans: [
|
||||
new TraceSpan(
|
||||
SpanId: "span-1",
|
||||
ParentSpanId: null,
|
||||
OperationName: "TestOperation",
|
||||
StartTime: _timeProvider.GetUtcNow(),
|
||||
Duration: TimeSpan.FromMilliseconds(100),
|
||||
Attributes: attributes.ToImmutableDictionary(),
|
||||
Events: [])
|
||||
],
|
||||
TotalDuration: TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user