Makes the application say what it is doing and when it fails
U4. Console logging plus Sentry, a same-origin tunnel so ad blockers cannot silence browser errors, Umami on the admin SPA, and six security events that alert rules can actually be built on. The correlation id is the W3C trace id from the ambient Activity, enabled by one line of ActivityTrackingOptions so every entry from every category carries it without touching a call site. It propagates across the master/slave boundary via traceparent, which TraceIdentifier cannot do at all, and it is the same value ProblemDetails already returns to the browser. The security events use source-generated LoggerMessage with constant templates. Sentry groups log events by message, so interpolating an email address would give every address its own issue and "more than 20 failed logins in five minutes" could never fire — the events would arrive, be visible, be tagged, and the alerting would silently be impossible. A test asserts the rendered message is identical across argument values. Scrubbing happens in-process, before transmission, and covers Set-Cookie as well as Cookie: the login response issues the refreshToken there, so scrubbing only the request side would protect nothing. Transactions are scrubbed too, because they carry request data and are the channel nobody thinks of. The tunnel derives its destination from the DSN once at startup and reads nothing from the request, which is what separates a tunnel from a server-side request forgery primitive. Size is capped by a bounded read rather than by trusting Content-Length, and the endpoint is rate limited. Two things found along the way. Zod 4's url() hands the value to the URL constructor, which accepts any scheme — so the existing frontend validation would have accepted the exact "htp://" typo BR-U4-24 names, and the SPA would have called a nonexistent origin. Now constrained to http(s). And the new appsettings comments are verified against the real configuration provider, because the failure mode if it rejected them is both hosts refusing to start after a release switch. One deviation. IAdminTokenValidator was meant to gain a reason-reporting overload; implemented that way, a substitute returning false by default silently inverted the access decision while both methods compiled. Two methods whose difference is invisible at the call site is the defect, so it is now a single Validate returning AdminTokenResult. Touches two files from already-committed units: DatabaseMigrationExtensions (U2) gains a flush before the rethrow, or the one Critical event in the system dies with the process; AdminTokenValidator (U1) classifies why a bypass was refused. Build 0 errors; 366 backend tests pass, up from 315, and 237 frontend tests, up from 213. tsc clean, eslint clean on every changed file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
@@ -46,7 +46,7 @@ public class AdminTokenValidatorTests
|
||||
{
|
||||
var header = $"Bearer {CreateToken(role)}";
|
||||
|
||||
_validator.IsVerifiedAdmin(header).Should().BeTrue();
|
||||
_validator.Validate(header).IsVerifiedAdmin.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -54,7 +54,7 @@ public class AdminTokenValidatorTests
|
||||
{
|
||||
var header = $"Bearer {CreateToken("User")}";
|
||||
|
||||
_validator.IsVerifiedAdmin(header).Should().BeFalse();
|
||||
_validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -64,7 +64,7 @@ public class AdminTokenValidatorTests
|
||||
// signed by us. Reading claims without validating would have accepted this.
|
||||
var forged = CreateUnsignedToken("Owner");
|
||||
|
||||
_validator.IsVerifiedAdmin($"Bearer {forged}").Should().BeFalse();
|
||||
_validator.Validate($"Bearer {forged}").IsVerifiedAdmin.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -83,7 +83,7 @@ public class AdminTokenValidatorTests
|
||||
|
||||
var header = $"Bearer {new JwtSecurityTokenHandler().WriteToken(token)}";
|
||||
|
||||
_validator.IsVerifiedAdmin(header).Should().BeFalse();
|
||||
_validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -91,7 +91,7 @@ public class AdminTokenValidatorTests
|
||||
{
|
||||
var header = $"Bearer {CreateToken("Owner", expiresIn: TimeSpan.FromMinutes(-5))}";
|
||||
|
||||
_validator.IsVerifiedAdmin(header).Should().BeFalse();
|
||||
_validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -99,7 +99,7 @@ public class AdminTokenValidatorTests
|
||||
{
|
||||
var header = $"Bearer {CreateToken("Owner", issuer: "SomeoneElse")}";
|
||||
|
||||
_validator.IsVerifiedAdmin(header).Should().BeFalse();
|
||||
_validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -115,7 +115,7 @@ public class AdminTokenValidatorTests
|
||||
{
|
||||
// Never throws — an unusable header simply means "not an admin". Rejecting the request
|
||||
// is the authentication middleware's job, not the availability gate's.
|
||||
_validator.IsVerifiedAdmin(header).Should().BeFalse();
|
||||
_validator.Validate(header).IsVerifiedAdmin.Should().BeFalse();
|
||||
}
|
||||
|
||||
private static string CreateToken(
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SlpModularCms.Core.Hosting.Observability;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the committed <c>appsettings.json</c> of both hosts and runs the startup validators
|
||||
/// against them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything asserted here is a startup failure in production if it is wrong, which means the
|
||||
/// symptom is a host that will not boot after a release switch. Two specific reasons this exists:
|
||||
///
|
||||
/// The files carry <c>//</c> comments explaining non-obvious values. The JSON configuration
|
||||
/// provider does tolerate them, but "does tolerate" is worth verifying rather than assuming when
|
||||
/// the failure mode is both hosts refusing to start.
|
||||
///
|
||||
/// And <c>SecurityHeaders</c> policy names are validated with <c>ValidateOnStart</c>, so a typo in
|
||||
/// the committed file stops the process. Better to fail here than in a deployment.
|
||||
/// </remarks>
|
||||
public class DeployedConfigurationTests
|
||||
{
|
||||
public static TheoryData<string> HostConfigurations => new()
|
||||
{
|
||||
Path.Combine("host-config", "master.appsettings.json"),
|
||||
Path.Combine("host-config", "slave.appsettings.json")
|
||||
};
|
||||
|
||||
private static IConfigurationRoot Load(string relativePath)
|
||||
{
|
||||
File.Exists(relativePath).Should().BeTrue($"'{relativePath}' should be copied to the test output");
|
||||
|
||||
return new ConfigurationBuilder()
|
||||
.AddJsonFile(relativePath, optional: false)
|
||||
.Build();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(HostConfigurations))]
|
||||
public void HostConfiguration_ShouldParse_IncludingItsComments(string relativePath)
|
||||
{
|
||||
var act = () => Load(relativePath);
|
||||
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(HostConfigurations))]
|
||||
public void HostConfiguration_ShouldPassSecurityHeadersStartupValidation(string relativePath)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddCmsSecurityHeaders(Load(relativePath));
|
||||
|
||||
var act = () => services.BuildServiceProvider().GetRequiredService<IStartupValidator>().Validate();
|
||||
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(HostConfigurations))]
|
||||
public void HostConfiguration_ShouldShipWithoutASentryDsn(string relativePath)
|
||||
{
|
||||
var options = Load(relativePath)
|
||||
.GetSection(ObservabilityOptions.SectionName)
|
||||
.Get<ObservabilityOptions>();
|
||||
|
||||
options.Should().NotBeNull();
|
||||
// A DSN identifies a project and belongs to an account. It is supplied per environment as
|
||||
// Observability__SentryDsn; the repository is not where it should be written down.
|
||||
options!.IsSentryConfigured.Should().BeFalse();
|
||||
options.TunnelMaxPayloadBytes.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// At <c>Information</c>, EF's command logging prints every SQL statement including parameter
|
||||
/// values, and the login path passes a normalised email address through it. Raising the
|
||||
/// default log level without pinning this category would start writing that to the console and
|
||||
/// to Sentry breadcrumbs.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(HostConfigurations))]
|
||||
public void HostConfiguration_ShouldPinEfCommandLoggingBelowInformation(string relativePath)
|
||||
{
|
||||
var level = Load(relativePath)["Logging:LogLevel:Microsoft.EntityFrameworkCore.Database.Command"];
|
||||
|
||||
level.Should().Be("Warning");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sentry breadcrumbs are capped by the logging level, so leaving the default at Warning would
|
||||
/// deliver every event with an empty breadcrumb trail — the feature present, configured, and
|
||||
/// useless.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(HostConfigurations))]
|
||||
public void HostConfiguration_ShouldAllowInformationLevelLogging(string relativePath)
|
||||
{
|
||||
var configuration = Load(relativePath);
|
||||
|
||||
configuration["Logging:LogLevel:Default"].Should().Be("Information");
|
||||
configuration["Logging:LogLevel:Microsoft.AspNetCore"].Should().Be("Information");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(HostConfigurations))]
|
||||
public void HostConfiguration_ShouldRateLimitTheSentryTunnel(string relativePath)
|
||||
{
|
||||
var configuration = Load(relativePath);
|
||||
|
||||
configuration.GetValue<int>("RateLimiting:SentryTunnel:PermitLimit").Should().BeGreaterThan(0);
|
||||
configuration.GetValue<int>("RateLimiting:SentryTunnel:WindowSeconds").Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>The admin SPA must never fall to the relaxed policy on the host that serves it.</summary>
|
||||
[Fact]
|
||||
public void MasterHostConfiguration_ShouldApplyTheStrictPolicyToAdmin()
|
||||
{
|
||||
var options = Load(Path.Combine("host-config", "master.appsettings.json"))
|
||||
.GetSection(SecurityHeadersOptions.SectionName)
|
||||
.Get<SecurityHeadersOptions>();
|
||||
|
||||
options.Should().NotBeNull();
|
||||
options!.Enabled.Should().BeTrue();
|
||||
options.PathPolicies
|
||||
.Should().Contain(rule => rule.PathPrefix == "/admin" && rule.Policy == CspPolicyCatalog.Strict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Sentry;
|
||||
using SlpModularCms.Core.Hosting.Observability;
|
||||
using SlpModularCms.Core.Observability;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the property that makes alert rules possible at all.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sentry groups log-derived events by their message. If a failed-login entry interpolated the
|
||||
/// email address, every distinct address would become its own Sentry issue and an alert rule of
|
||||
/// the form "more than 20 failed logins in five minutes" could never fire, because no single
|
||||
/// issue would ever reach 20. Everything would look like it worked: events arrive, they are
|
||||
/// visible, they are tagged. Only the alerting would silently be impossible.
|
||||
///
|
||||
/// These tests assert that the rendered message is identical across different argument values.
|
||||
/// </remarks>
|
||||
public class SecurityEventsTests
|
||||
{
|
||||
private sealed class CapturingLogger : ILogger
|
||||
{
|
||||
public List<(LogLevel Level, EventId EventId, string Message)> Entries { get; } = [];
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Entries.Add((logLevel, eventId, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedLogin_ShouldRenderTheSameMessage_ForDifferentAccounts()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
|
||||
SecurityEvents.FailedLogin(logger, "/api/v1/Auth/login", accountExists: true);
|
||||
SecurityEvents.FailedLogin(logger, "/api/v1/Auth/login", accountExists: true);
|
||||
|
||||
logger.Entries.Should().HaveCount(2);
|
||||
logger.Entries[0].Message.Should().Be(logger.Entries[1].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedLogin_ShouldCarryTheEventNameAndNoCredentials()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
|
||||
SecurityEvents.FailedLogin(logger, "/api/v1/Auth/login", accountExists: false);
|
||||
|
||||
var entry = logger.Entries.Single();
|
||||
entry.Message.Should().Contain(SecurityEventNames.FailedLogin);
|
||||
entry.EventId.Id.Should().Be(SecurityEvents.FailedLoginEventId);
|
||||
entry.Level.Should().Be(LogLevel.Warning);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// All six must cross the Sentry event threshold by construction rather than by a
|
||||
/// coincidence of configuration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AllEvents_ShouldBeWarningOrAbove()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
|
||||
SecurityEvents.FailedLogin(logger, "/e", accountExists: true);
|
||||
SecurityEvents.AuthorizationDenied(logger, "/e", "OwnerOnly");
|
||||
SecurityEvents.MasterApiKeyRejected(logger, "/e", "10.0.0.1");
|
||||
SecurityEvents.AdminBypassRejected(logger, "/e", BypassRejectionReason.InvalidSignature);
|
||||
SecurityEvents.RateLimitTriggered(logger, "login", "/e");
|
||||
SecurityEvents.MigrationFailure(logger, new InvalidOperationException("boom"), attempts: 5);
|
||||
|
||||
logger.Entries.Should().HaveCount(6);
|
||||
logger.Entries.Should().OnlyContain(e => e.Level >= LogLevel.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEvents_ShouldUseDistinctEventIds()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
|
||||
SecurityEvents.FailedLogin(logger, "/e", accountExists: true);
|
||||
SecurityEvents.AuthorizationDenied(logger, "/e", "OwnerOnly");
|
||||
SecurityEvents.MasterApiKeyRejected(logger, "/e", "10.0.0.1");
|
||||
SecurityEvents.AdminBypassRejected(logger, "/e", BypassRejectionReason.Expired);
|
||||
SecurityEvents.RateLimitTriggered(logger, "login", "/e");
|
||||
SecurityEvents.MigrationFailure(logger, new InvalidOperationException("boom"), attempts: 1);
|
||||
|
||||
logger.Entries.Select(e => e.EventId.Id).Should().OnlyHaveUniqueItems();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MigrationFailure_ShouldBeCritical()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
|
||||
SecurityEvents.MigrationFailure(logger, new InvalidOperationException("boom"), attempts: 5);
|
||||
|
||||
logger.Entries.Single().Level.Should().Be(LogLevel.Critical);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reason class distinguishes forgery from a stale tab. The token itself must never
|
||||
/// appear anywhere in the entry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AdminBypassRejected_ShouldCarryTheReasonClass()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
|
||||
SecurityEvents.AdminBypassRejected(logger, "/admin", BypassRejectionReason.InvalidSignature);
|
||||
|
||||
logger.Entries.Single().Message.Should().Contain(nameof(BypassRejectionReason.InvalidSignature));
|
||||
}
|
||||
}
|
||||
|
||||
public class SecurityEventProcessorTests
|
||||
{
|
||||
private readonly SecurityEventProcessor _processor = new();
|
||||
|
||||
[Fact]
|
||||
public void Process_ShouldTagKnownSecurityEvents()
|
||||
{
|
||||
var sentryEvent = new SentryEvent();
|
||||
sentryEvent.SetExtra("SecurityEvent", SecurityEventNames.FailedLogin);
|
||||
|
||||
var processed = _processor.Process(sentryEvent);
|
||||
|
||||
processed!.Tags[SecurityEventProcessor.TagName].Should().Be(SecurityEventNames.FailedLogin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alert rules filter on the tag rather than on message text, because a rule that matches
|
||||
/// nothing looks exactly like a rule with nothing to match.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Process_ShouldNotTagUnknownValues()
|
||||
{
|
||||
var sentryEvent = new SentryEvent();
|
||||
sentryEvent.SetExtra("SecurityEvent", "something_else");
|
||||
|
||||
var processed = _processor.Process(sentryEvent);
|
||||
|
||||
processed!.Tags.Should().NotContainKey(SecurityEventProcessor.TagName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Process_ShouldLeaveOrdinaryEventsUntouched()
|
||||
{
|
||||
var processed = _processor.Process(new SentryEvent());
|
||||
|
||||
processed!.Tags.Should().NotContainKey(SecurityEventProcessor.TagName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using FluentAssertions;
|
||||
using Sentry;
|
||||
using SlpModularCms.Core.Hosting.Observability;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// The most security-critical code in the observability unit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Enabling <c>SendDefaultPii</c> attaches request headers, and this application carries two
|
||||
/// standing credentials in them: the <c>refreshToken</c> cookie and the master/slave shared
|
||||
/// secret. Sending either to a third party would be worse than the problem the setting solves.
|
||||
/// These tests exist so that the scrub list cannot quietly shrink.
|
||||
/// </remarks>
|
||||
public class SentryEventScrubberTests
|
||||
{
|
||||
private readonly SentryEventScrubber _scrubber = new();
|
||||
|
||||
private static SentryEvent CreateEventWithCredentials()
|
||||
{
|
||||
var sentryEvent = new SentryEvent();
|
||||
sentryEvent.Request.Method = "POST";
|
||||
sentryEvent.Request.Url = "https://example.com/api/v1/Auth/login";
|
||||
sentryEvent.Request.QueryString = "returnUrl=/admin";
|
||||
sentryEvent.Request.Headers["Cookie"] = "refreshToken=super-secret-value";
|
||||
sentryEvent.Request.Headers["Set-Cookie"] = "refreshToken=freshly-issued; HttpOnly";
|
||||
sentryEvent.Request.Headers["Authorization"] = "Bearer eyJhbGciOi...";
|
||||
sentryEvent.Request.Headers["X-Master-Api-Key"] = "the-shared-secret";
|
||||
sentryEvent.Request.Headers["User-Agent"] = "Mozilla/5.0";
|
||||
sentryEvent.Request.Data = "{\"email\":\"a@b.nl\",\"password\":\"hunter2\"}";
|
||||
return sentryEvent;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Cookie")]
|
||||
[InlineData("Set-Cookie")]
|
||||
[InlineData("Authorization")]
|
||||
[InlineData("X-Master-Api-Key")]
|
||||
public void Scrub_ShouldRemoveCredentialHeaders(string header)
|
||||
{
|
||||
var scrubbed = _scrubber.Scrub(CreateEventWithCredentials());
|
||||
|
||||
scrubbed.Request.Headers.Should().NotContainKey(header);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrub_ShouldRemoveTheRequestBody()
|
||||
{
|
||||
var scrubbed = _scrubber.Scrub(CreateEventWithCredentials());
|
||||
|
||||
scrubbed.Request.Data.Should().BeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The retained fields are the entire diagnostic value of the request context. Scrubbing
|
||||
/// everything would be safe and useless.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Scrub_ShouldRetainDiagnosticFields()
|
||||
{
|
||||
var scrubbed = _scrubber.Scrub(CreateEventWithCredentials());
|
||||
|
||||
scrubbed.Request.Method.Should().Be("POST");
|
||||
scrubbed.Request.Url.Should().Be("https://example.com/api/v1/Auth/login");
|
||||
scrubbed.Request.QueryString.Should().Be("returnUrl=/admin");
|
||||
scrubbed.Request.Headers.Should().ContainKey("User-Agent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrub_ShouldNotThrow_WhenNoRequestContextIsPresent()
|
||||
{
|
||||
var act = () => _scrubber.Scrub(new SentryEvent());
|
||||
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transactions carry request data too, and are the channel nobody thinks of. Raising
|
||||
/// TracesSampleRate without this would start leaking headers.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Cookie")]
|
||||
[InlineData("Authorization")]
|
||||
[InlineData("X-Master-Api-Key")]
|
||||
public void ScrubTransaction_ShouldRemoveCredentialHeaders(string header)
|
||||
{
|
||||
var transaction = new SentryTransaction("test", "http.server");
|
||||
transaction.Request.Headers["Cookie"] = "refreshToken=secret";
|
||||
transaction.Request.Headers["Authorization"] = "Bearer token";
|
||||
transaction.Request.Headers["X-Master-Api-Key"] = "secret";
|
||||
transaction.Request.Data = "body";
|
||||
|
||||
var scrubbed = _scrubber.ScrubTransaction(transaction);
|
||||
|
||||
scrubbed.Request.Headers.Should().NotContainKey(header);
|
||||
scrubbed.Request.Data.Should().BeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A guard against the list shrinking by accident. Each entry has a reason recorded next to
|
||||
/// it in the implementation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RemovedHeaders_ShouldCoverAllFourCredentialCarriers()
|
||||
{
|
||||
SentryEventScrubber.RemovedHeaders.Should().BeEquivalentTo(
|
||||
["Cookie", "Set-Cookie", "Authorization", "X-Master-Api-Key"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SlpModularCms.Core.Hosting.Observability;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// The destination is the rule that keeps the tunnel from being a server-side request forgery
|
||||
/// primitive: it is computed once from configuration, and nothing in a request can influence it.
|
||||
/// </summary>
|
||||
public class SentryTunnelTargetTests
|
||||
{
|
||||
private static SentryTunnelTarget Create(string dsn, int maxBytes = 204_800) =>
|
||||
new(Options.Create(new ObservabilityOptions
|
||||
{
|
||||
SentryDsn = dsn,
|
||||
TunnelMaxPayloadBytes = maxBytes
|
||||
}));
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ShouldDeriveTheEnvelopeEndpoint_FromTheDsn()
|
||||
{
|
||||
var target = Create("https://abc123@o4511795618185216.ingest.de.sentry.io/4511795622838352");
|
||||
|
||||
target.IsConfigured.Should().BeTrue();
|
||||
target.EnvelopeEndpoint.Should().Be(
|
||||
new Uri("https://o4511795618185216.ingest.de.sentry.io/api/4511795622838352/envelope/"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ShouldNotBeConfigured_WithoutADsn()
|
||||
{
|
||||
var target = Create(string.Empty);
|
||||
|
||||
target.IsConfigured.Should().BeFalse();
|
||||
target.EnvelopeEndpoint.Should().BeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fails at startup rather than per request, consistent with failing closed everywhere else.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("not-a-uri")]
|
||||
[InlineData("https://abc123@ingest.sentry.io")] // no project id
|
||||
[InlineData("https://abc123@ingest.sentry.io/")]
|
||||
public void Constructor_ShouldThrow_ForAnUnusableDsn(string dsn)
|
||||
{
|
||||
var act = () => Create(dsn);
|
||||
|
||||
act.Should().Throw<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ShouldFallBackToADefault_WhenTheSizeCapIsNotPositive()
|
||||
{
|
||||
var target = Create("https://abc@host.sentry.io/42", maxBytes: 0);
|
||||
|
||||
target.MaxPayloadBytes.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>Kept in sync with the frontend's <c>tunnel</c> option and the vite dev proxy.</summary>
|
||||
[Fact]
|
||||
public void Path_ShouldBeOutsideTheVersionedApi()
|
||||
{
|
||||
SentryTunnelTarget.Path.Should().Be("/sentry-tunnel");
|
||||
SentryTunnelTarget.Path.Should().NotStartWith("/api/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SlpModularCms.Core.Hosting;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using SlpModularCms.Core.Identity.Models;
|
||||
using SlpModularCms.Core.Observability;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting.Security;
|
||||
|
||||
/// <summary>
|
||||
/// The rejection reason is what makes a rejected admin bypass actionable: InvalidSignature means
|
||||
/// someone is forging tokens, Expired is almost always an administrator with a stale tab, and an
|
||||
/// alert that cannot tell those apart is one nobody acts on.
|
||||
/// </summary>
|
||||
public class AdminTokenRejectionReasonTests
|
||||
{
|
||||
private const string Secret = "TestSecretKeyThatIsLongEnoughForHmacSha256Signing!!";
|
||||
private const string Issuer = "SlpModularCms";
|
||||
private const string Audience = "SlpModularCmsPortal";
|
||||
|
||||
private readonly AdminTokenValidator _validator = new(JwtTokenValidation.Create(new JwtSettings
|
||||
{
|
||||
Secret = Secret,
|
||||
Issuer = Issuer,
|
||||
Audience = Audience
|
||||
}));
|
||||
|
||||
private static string CreateToken(
|
||||
string? secret = null,
|
||||
string? issuer = null,
|
||||
string? audience = null,
|
||||
string role = "Owner",
|
||||
TimeSpan? lifetime = null)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret ?? Secret));
|
||||
var expires = DateTime.UtcNow.Add(lifetime ?? TimeSpan.FromMinutes(30));
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: issuer ?? Issuer,
|
||||
audience: audience ?? Audience,
|
||||
claims: [new Claim(ClaimTypes.Role, role)],
|
||||
notBefore: expires.AddMinutes(-35),
|
||||
expires: expires,
|
||||
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("Basic abc")]
|
||||
[InlineData("Bearer ")]
|
||||
public void Validate_ShouldReportAbsent_WhenThereIsNoBearerToken(string? header)
|
||||
{
|
||||
var result = _validator.Validate(header);
|
||||
|
||||
result.IsVerifiedAdmin.Should().BeFalse();
|
||||
result.Reason.Should().Be(BypassRejectionReason.Absent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ShouldReportInvalidSignature_ForAForgedToken()
|
||||
{
|
||||
var forged = CreateToken(secret: "AnEntirelyDifferentSecretThatIsAlsoLongEnough!!!!!");
|
||||
|
||||
var result = _validator.Validate($"Bearer {forged}");
|
||||
|
||||
result.IsVerifiedAdmin.Should().BeFalse();
|
||||
result.Reason.Should().Be(BypassRejectionReason.InvalidSignature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ShouldReportExpired_ForAStaleToken()
|
||||
{
|
||||
var expired = CreateToken(lifetime: TimeSpan.FromMinutes(-10));
|
||||
|
||||
var result = _validator.Validate($"Bearer {expired}");
|
||||
|
||||
result.IsVerifiedAdmin.Should().BeFalse();
|
||||
result.Reason.Should().Be(BypassRejectionReason.Expired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ShouldReportWrongIssuer()
|
||||
{
|
||||
var token = CreateToken(issuer: "SomeoneElse");
|
||||
|
||||
var result = _validator.Validate($"Bearer {token}");
|
||||
|
||||
result.IsVerifiedAdmin.Should().BeFalse();
|
||||
result.Reason.Should().Be(BypassRejectionReason.WrongIssuer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ShouldReportWrongAudience()
|
||||
{
|
||||
var token = CreateToken(audience: "SomeOtherAudience");
|
||||
|
||||
var result = _validator.Validate($"Bearer {token}");
|
||||
|
||||
result.IsVerifiedAdmin.Should().BeFalse();
|
||||
result.Reason.Should().Be(BypassRejectionReason.WrongAudience);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ShouldReportNotAdmin_ForAValidNonAdminToken()
|
||||
{
|
||||
var token = CreateToken(role: "User");
|
||||
|
||||
var result = _validator.Validate($"Bearer {token}");
|
||||
|
||||
result.IsVerifiedAdmin.Should().BeFalse();
|
||||
result.Reason.Should().Be(BypassRejectionReason.NotAdmin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ShouldReportMalformed_ForGarbage()
|
||||
{
|
||||
var result = _validator.Validate("Bearer not.a.jwt");
|
||||
|
||||
result.IsVerifiedAdmin.Should().BeFalse();
|
||||
result.Reason.Should().Be(BypassRejectionReason.Malformed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ShouldStillSucceed_ForAValidAdminToken()
|
||||
{
|
||||
var token = CreateToken();
|
||||
|
||||
_validator.Validate($"Bearer {token}").IsVerifiedAdmin.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -38,7 +39,7 @@ public class AuthServiceTests
|
||||
RefreshTokenExpiryDays = 7
|
||||
};
|
||||
|
||||
_service = new AuthService(_userManager, _context, Options.Create(jwtSettings));
|
||||
_service = new AuthService(_userManager, _context, Options.Create(jwtSettings), NullLogger<AuthService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -31,4 +31,14 @@
|
||||
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
The real, committed appsettings of both hosts, so DeployedConfigurationTests can validate them
|
||||
rather than a copy that drifts. Linked, not duplicated: a test asserting a second file proves
|
||||
nothing about what actually ships.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<Content Include="..\SlpModularCms.Api\appsettings.json" Link="host-config\master.appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="..\SlpModularCms.Api.Slave\appsettings.json" Link="host-config\slave.appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user