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()); 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(), 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(); hub.Setup(h => h.StartTransaction( It.IsAny(), It.IsAny>())) .Returns(transaction.Object); using var handle = SentryObservability.BeginBackgroundTransaction( hub.Object, "workorders.job", "MyWorker.Run", "the-reason"); hub.Verify(h => h.StartTransaction( It.Is(context => context.Name == "workorders.job" && context.Operation == "task"), It.IsAny>()), 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()), Times.Once); transaction.Verify(t => t.SetTag("transaction_id", It.IsAny()), Times.Once); scope.Tags["operation.type"].Should().Be("background_job"); } [Fact] public void BeginBackgroundTransaction_Handle_CannotDoubleFinish() { var (hub, _) = CreateHub(); var transaction = new Mock(); hub.Setup(h => h.StartTransaction( It.IsAny(), It.IsAny>())) .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(), It.IsAny()), Times.Never); transaction.Verify(t => t.Finish(SpanStatus.Cancelled), Times.Never); } [Fact] public void Dispose_UnfinishedTransaction_FinishesOnceWithUnknownErrorStatus() { var (hub, _) = CreateHub(); var transaction = new Mock(); hub.Setup(h => h.StartTransaction( It.IsAny(), It.IsAny>())) .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()), Times.Once); } [Fact] public void Dispose_AfterFinish_DoesNotFinishAgain() { var (hub, _) = CreateHub(); var transaction = new Mock(); hub.Setup(h => h.StartTransaction( It.IsAny(), It.IsAny>())) .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()), 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 Hub, Scope Scope) CreateHub() { var hub = new Mock(); var scope = new Scope(new SentryOptions()); hub.Setup(h => h.ConfigureScope(It.IsAny>())) .Callback>(action => action(scope)); hub.Setup(h => h.PushScope()).Returns(Mock.Of()); var activeSpan = new Mock(); 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(); 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; } }