mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
359 lines
14 KiB
C#
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;
|
|
}
|
|
}
|