shoc-backend/Api.SeaHavenIndustries.Tests/SentryObservabilityTests.cs
2026-09-15 16:03:18 -03:00

359 lines
14 KiB
C#

using System.Reflection;
using System.Security.Claims;
using Api.SeaHavenIndustries.Observability;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
using Moq;
using Sentry;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class SentryObservabilityTests
{
private static readonly string ValidSha = new string('a', 40);
private static readonly string UpperSha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
[Fact]
public void ResolveRelease_Accepts_ServicePrefixed40HexCommit()
{
SentryObservability.ResolveRelease("shoc-backend@" + ValidSha)
.Should().Be("shoc-backend@" + ValidSha);
}
[Theory]
[InlineData(null)]
[InlineData("1.0.0")]
[InlineData("shoc-backend@abc")]
[InlineData("shoc-backend@not-a-sha-at-all")]
[InlineData("other-service@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
[InlineData("SHOC-BACKEND@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
[InlineData("")]
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")]
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+dirty")]
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+")]
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa++")]
[InlineData("shoc-backend@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+extra")]
public void ResolveRelease_FallsBackToLocalDevelopment_ForNonReleaseVersion(string? version)
{
SentryObservability.ResolveRelease(version)
.Should().Be(SentryObservability.LocalDevelopmentRelease);
SentryObservability.ResolveCommitSha(version)
.Should().BeNull();
}
[Fact]
public void ResolveRelease_Accepts_SdkAppendedMatchingSourceRevisionSuffix()
{
var informationalVersion = "shoc-backend@" + ValidSha + "+" + ValidSha;
SentryObservability.ResolveRelease(informationalVersion)
.Should().Be("shoc-backend@" + ValidSha);
SentryObservability.ResolveCommitSha(informationalVersion)
.Should().Be(ValidSha);
}
[Fact]
public void ResolveRelease_Accepts_SuffixDifferingOnlyInCase_FromTheCommitSha()
{
var canonicalSha = UpperSha.ToLowerInvariant();
SentryObservability.ResolveRelease("shoc-backend@" + UpperSha + "+" + canonicalSha)
.Should().Be("shoc-backend@" + canonicalSha);
}
[Fact]
public void ResolveRelease_NormalizesUppercaseCommitShaToLowercase()
{
var canonicalSha = UpperSha.ToLowerInvariant();
SentryObservability.ResolveRelease("shoc-backend@" + UpperSha)
.Should().Be("shoc-backend@" + canonicalSha);
SentryObservability.ResolveCommitSha("shoc-backend@" + UpperSha)
.Should().Be(canonicalSha);
}
[Fact]
public void ResolveCommitSha_ReturnsSha_WhenReleaseShaped()
{
var release = SentryObservability.ResolveRelease("shoc-backend@" + ValidSha);
release[(SentryObservability.ServiceName.Length + 1)..].Should().Be(ValidSha);
SentryObservability.ResolveCommitSha("shoc-backend@" + ValidSha)
.Should().Be(ValidSha);
}
[Fact]
public void ResolveRelease_AndCommitSha_FromNonReleaseAssembly_FallBackSafely()
{
var assembly = typeof(SentryObservabilityTests).Assembly;
SentryObservability.ResolveRelease(assembly)
.Should().Be(SentryObservability.LocalDevelopmentRelease);
SentryObservability.ResolveCommitSha(assembly)
.Should().BeNull();
}
[Fact]
public void ResolveRelease_AndCommitSha_FromNullAssembly_FallBackSafely()
{
SentryObservability.ResolveRelease((Assembly?)null)
.Should().Be(SentryObservability.LocalDevelopmentRelease);
SentryObservability.ResolveCommitSha((Assembly?)null)
.Should().BeNull();
}
[Fact]
public void ConfigureRequest_SetsUser_FromNameIdentifierOnly_AndUsesRouteTemplateNotValues()
{
var (hub, scope) = CreateHub();
var context = CreateHttpContext(
routePattern: "/api/work-orders/{workOrderId}",
claims: new[]
{
new Claim(ClaimTypes.NameIdentifier, "user-123"),
new Claim(ClaimTypes.Email, "secret.user@example.com"),
new Claim(ClaimTypes.Name, "Secret User"),
});
context.Request.QueryString = new QueryString("?ticket=secret-query-value");
context.Request.Method = "GET";
SentryObservability.ConfigureRequest(hub.Object, context);
scope.User.Should().NotBeNull();
scope.User!.Id.Should().Be("user-123");
scope.User.Email.Should().BeNull();
scope.User.Username.Should().BeNull();
scope.Tags["actor.type"].Should().Be("authenticated");
scope.Tags["operation.type"].Should().Be("http.server");
scope.Tags["code.function"].Should().Be("WorkOrders.GetWorkOrder");
scope.Tags["http.method"].Should().Be("GET");
scope.Tags["http.route"].Should().Be("/api/work-orders/{workOrderId}");
scope.Tags["trace_id"].Should().NotBeNullOrWhiteSpace();
scope.Tags["transaction_id"].Should().NotBeNullOrWhiteSpace();
scope.Tags.Values.Should().NotContain(value => value != null && value.Contains("secret", StringComparison.OrdinalIgnoreCase),
"no query, header, body, cookie, name, or email material may reach Sentry tags");
}
[Fact]
public void ConfigureRequest_AnonymousRequest_TagsAnonymous_AndSetsNoUser()
{
var (hub, scope) = CreateHub();
var context = CreateHttpContext("/api/vendors", Array.Empty<Claim>());
SentryObservability.ConfigureRequest(hub.Object, context);
scope.User.Id.Should().BeNull();
scope.Tags["actor.type"].Should().Be("anonymous");
scope.Tags["code.function"].Should().Be("Vendors.List");
scope.Tags["http.route"].Should().Be("/api/vendors");
}
[Fact]
public void ConfigureRequest_NonControllerEndpoint_OmitsCodeFunction()
{
var (hub, scope) = CreateHub();
var context = CreateHttpContext("/health", Array.Empty<Claim>(), includeActionDescriptor: false);
SentryObservability.ConfigureRequest(hub.Object, context);
scope.Tags.Should().NotContainKey("code.function");
}
[Fact]
public void BeginBackgroundTransaction_SetsScopeTransaction_AndSystemTags()
{
var (hub, scope) = CreateHub();
var transaction = new Mock<ITransactionTracer>();
hub.Setup(h => h.StartTransaction(
It.IsAny<ITransactionContext>(),
It.IsAny<IReadOnlyDictionary<string, object?>>()))
.Returns(transaction.Object);
using var handle = SentryObservability.BeginBackgroundTransaction(
hub.Object,
"workorders.job",
"MyWorker.Run",
"the-reason");
hub.Verify(h => h.StartTransaction(
It.Is<ITransactionContext>(context => context.Name == "workorders.job" && context.Operation == "task"),
It.IsAny<IReadOnlyDictionary<string, object?>>()), Times.Once);
scope.Transaction.Should().BeSameAs(transaction.Object);
transaction.VerifySet(t => t.Description = "the-reason", Times.Once);
transaction.Verify(t => t.SetTag("actor.type", "system"), Times.Once);
transaction.Verify(t => t.SetTag("operation.type", "background_job"), Times.Once);
transaction.Verify(t => t.SetTag("code.function", "MyWorker.Run"), Times.Once);
transaction.Verify(t => t.SetTag("trace_id", It.IsAny<string>()), Times.Once);
transaction.Verify(t => t.SetTag("transaction_id", It.IsAny<string>()), Times.Once);
scope.Tags["operation.type"].Should().Be("background_job");
}
[Fact]
public void BeginBackgroundTransaction_Handle_CannotDoubleFinish()
{
var (hub, _) = CreateHub();
var transaction = new Mock<ITransactionTracer>();
hub.Setup(h => h.StartTransaction(
It.IsAny<ITransactionContext>(),
It.IsAny<IReadOnlyDictionary<string, object?>>()))
.Returns(transaction.Object);
using var handle = SentryObservability.BeginBackgroundTransaction(hub.Object, "job", "Fn");
handle.FinishOk();
handle.FinishOk();
handle.FinishError(new InvalidOperationException("late"));
handle.FinishCancelled();
transaction.Verify(t => t.Finish(SpanStatus.Ok), Times.Once);
transaction.Verify(t => t.Finish(It.IsAny<Exception>(), It.IsAny<SpanStatus>()), Times.Never);
transaction.Verify(t => t.Finish(SpanStatus.Cancelled), Times.Never);
}
[Fact]
public void Dispose_UnfinishedTransaction_FinishesOnceWithUnknownErrorStatus()
{
var (hub, _) = CreateHub();
var transaction = new Mock<ITransactionTracer>();
hub.Setup(h => h.StartTransaction(
It.IsAny<ITransactionContext>(),
It.IsAny<IReadOnlyDictionary<string, object?>>()))
.Returns(transaction.Object);
var handle = SentryObservability.BeginBackgroundTransaction(hub.Object, "job", "Fn");
handle.Dispose();
handle.Dispose();
transaction.Verify(t => t.Finish(SpanStatus.UnknownError), Times.Once);
transaction.Verify(t => t.Finish(It.IsAny<SpanStatus>()), Times.Once);
}
[Fact]
public void Dispose_AfterFinish_DoesNotFinishAgain()
{
var (hub, _) = CreateHub();
var transaction = new Mock<ITransactionTracer>();
hub.Setup(h => h.StartTransaction(
It.IsAny<ITransactionContext>(),
It.IsAny<IReadOnlyDictionary<string, object?>>()))
.Returns(transaction.Object);
var handle = SentryObservability.BeginBackgroundTransaction(hub.Object, "job", "Fn");
handle.FinishOk();
handle.Dispose();
transaction.Verify(t => t.Finish(SpanStatus.Ok), Times.Once);
transaction.Verify(t => t.Finish(SpanStatus.UnknownError), Times.Never);
transaction.Verify(t => t.Finish(It.IsAny<SpanStatus>()), Times.Once);
}
[Fact]
public void TelemetryScrubber_RemovesRequestSecrets_AndKeepsOnlyOpaqueUserId()
{
var @event = new SentryEvent
{
User = new SentryUser
{
Id = "opaque-user-123",
Email = "secret.user@example.com",
Username = "Secret User",
IpAddress = "203.0.113.10",
},
Request = new SentryRequest
{
Data = "secret-body",
QueryString = "token=secret-query",
Cookies = "session=secret-cookie",
},
};
@event.Request.Headers["Authorization"] = "Bearer secret-token";
@event.Request.Env["private"] = "secret-env";
@event.Request.Other["private"] = "secret-other";
var scrubbed = SentryTelemetryScrubber.Scrub(@event);
scrubbed.Request!.Data.Should().BeNull();
scrubbed.Request.QueryString.Should().BeNull();
scrubbed.Request.Cookies.Should().BeNull();
scrubbed.Request.Headers.Should().BeEmpty();
scrubbed.Request.Env.Should().BeEmpty();
scrubbed.Request.Other.Should().BeEmpty();
scrubbed.User.Should().NotBeNull();
scrubbed.User!.Id.Should().Be("opaque-user-123");
scrubbed.User.Email.Should().BeNull();
scrubbed.User.Username.Should().BeNull();
scrubbed.User.IpAddress.Should().BeNull();
}
[Fact]
public void TelemetryScrubber_RemovesSensitiveTransactionData()
{
var transaction = new SentryTransaction("GET /api/work-orders/{id}", "http.server");
transaction.SetData("http.route", "/api/work-orders/{id}");
transaction.SetData("http.query", "token=secret-query");
#pragma warning disable CS0618 // Exercise the scrubber's legacy Extra cleanup contract.
transaction.SetExtra("secret", "secret-extra");
#pragma warning restore CS0618
var scrubbed = SentryTelemetryScrubber.Scrub(transaction);
scrubbed.Data.Should().ContainKey("http.route");
scrubbed.Data.Should().NotContainKey("http.query");
#pragma warning disable CS0618 // Exercise the scrubber's legacy Extra cleanup contract.
scrubbed.Extra.Should().NotContainKey("secret");
#pragma warning restore CS0618
scrubbed.Tags["trace_id"].Should().Be(scrubbed.TraceId.ToString());
scrubbed.Tags["transaction_id"].Should().Be(scrubbed.SpanId.ToString());
}
private static (Mock<IHub> Hub, Scope Scope) CreateHub()
{
var hub = new Mock<IHub>();
var scope = new Scope(new SentryOptions());
hub.Setup(h => h.ConfigureScope(It.IsAny<Action<Scope>>()))
.Callback<Action<Scope>>(action => action(scope));
hub.Setup(h => h.PushScope()).Returns(Mock.Of<IDisposable>());
var activeSpan = new Mock<ISpan>();
activeSpan.SetupGet(span => span.TraceId).Returns(SentryId.Create());
activeSpan.SetupGet(span => span.SpanId).Returns(SpanId.Create());
hub.Setup(h => h.GetSpan()).Returns(activeSpan.Object);
return (hub, scope);
}
private static HttpContext CreateHttpContext(
string routePattern,
Claim[] claims,
bool includeActionDescriptor = true)
{
var metadata = includeActionDescriptor
? new object[]
{
new ControllerActionDescriptor
{
ControllerName = routePattern.StartsWith("/api/work-orders") ? "WorkOrders" : "Vendors",
ActionName = routePattern.StartsWith("/api/work-orders") ? "GetWorkOrder" : "List",
},
}
: Array.Empty<object>();
var endpoint = new RouteEndpoint(
_ => Task.CompletedTask,
RoutePatternFactory.Parse(routePattern),
0,
new EndpointMetadataCollection(metadata),
"test-endpoint");
var context = new DefaultHttpContext();
context.SetEndpoint(endpoint);
context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
return context;
}
}