Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Threading.Channels;
using Bunit;
using AwesomeAssertions;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
using MudBlazor;
Expand All @@ -21,6 +22,7 @@ public sealed class ClientServicesDialogTests : AsyncBunitContext
public ClientServicesDialogTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
JSInterop.SetupModule("./js/client-services-focus.js").Mode = JSRuntimeMode.Loose;
Services.AddMudServices(options => options.PopoverOptions.CheckForPopoverProvider = false);
Services.AddSingleton<IClientServicesApiService>(_api);
Services.AddSingleton<IClientServicesLiveStreamService>(_live);
Expand Down Expand Up @@ -244,6 +246,49 @@ public async Task Launcher_Routing_Key_Change_Closes_Viewer_And_Cancels_Its_Stre
provider.WaitForAssertion(() => provider.FindAll("[data-testid='client-services-dialog']").Should().BeEmpty());
}

[Theory]
[InlineData("unchanged", true)]
[InlineData("tenant", false)]
[InlineData("agent", false)]
[InlineData("tenant-away-and-back", false)]
[InlineData("disposed", false)]
public async Task Launcher_Waits_For_Exact_Dismissal_And_Rejects_Obsolete_Focus(string scopeChange, bool shouldRestore)
{
var module = JSInterop.SetupModule("./js/client-services-focus.js");
module.Mode = JSRuntimeMode.Loose;
var dismissal = module.SetupVoid("waitForDismissal", _ => true);
var provider = Render<MudDialogProvider>();
var launcher = Render<ClientServicesLauncher>(parameters => parameters
.Add(component => component.TenantId, 3).Add(component => component.AgentId, AgentId));
var opening = launcher.Find("[data-testid='client-services-launcher']").ClickAsync(new MouseEventArgs());
provider.WaitForAssertion(() => provider.Find("[data-testid='close-services']").Should().NotBeNull());
var dialogId = provider.Find(".mud-dialog").Id!.TrimStart('_');
var focusBeforeClose = JSInterop.Invocations["Blazor._internal.domWrapper.focus"].Count;
provider.Find("[data-testid='close-services']").Click();
provider.WaitForAssertion(() =>
{
dismissal.Invocations.Should().ContainSingle();
dismissal.Invocations.Single().Arguments.Should().ContainSingle().Which.Should().Be(dialogId);
});
// The native provider has completed Result, but the controlled DOM-dismissal seam is pending.
JSInterop.Invocations["Blazor._internal.domWrapper.focus"].Should().HaveCount(focusBeforeClose);
opening.IsCompleted.Should().BeFalse();

if (scopeChange is "tenant" or "tenant-away-and-back")
launcher.Render(parameters => parameters.Add(component => component.TenantId, 7));
if (scopeChange == "tenant-away-and-back")
launcher.Render(parameters => parameters.Add(component => component.TenantId, 3));
if (scopeChange == "agent")
launcher.Render(parameters => parameters.Add(component => component.AgentId, Guid.NewGuid()));
if (scopeChange == "disposed") await DisposeComponentsAsync();

dismissal.SetVoidResult();
await opening;
JSInterop.Invocations["Blazor._internal.domWrapper.focus"].Should()
.HaveCount(focusBeforeClose + (shouldRestore ? 1 : 0));
await _live.Cancelled.Task.WaitAsync(TimeSpan.FromSeconds(2));
}

private IRenderedComponent<ClientServicesDialog> RenderDialog() => Render<ClientServicesDialog>(parameters => parameters
.Add(component => component.TenantId, 3).Add(component => component.AgentId, AgentId)
.Add(component => component.HostLabel, "fixture-client").Add(component => component.Embedded, true));
Expand Down
3 changes: 2 additions & 1 deletion src/NetRatel/NetRatel.Web.ComponentTests/FlowEditorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ internal sealed class FakeFlowApi : IFlowApiService
public FlowSaveDraftRequest? LastSave;
public TaskCompletionSource<FlowDefinitionDto>? SavePending;
public Func<int, Guid, CancellationToken, Task<FlowVersionDto>>? VersionLookup;
public Func<int, Guid, CancellationToken, Task<FlowRunDetailDto>>? RunLookup;
public Task<IReadOnlyList<FlowTenantAccessDto>> GetTenantsAsync(CancellationToken token = default) => Task.FromResult<IReadOnlyList<FlowTenantAccessDto>>([new(17, "Tenant 17", true, true, true)]);
public Task<IReadOnlyList<FlowDefinitionDto>> ListAsync(int tenantId, CancellationToken token = default) => Task.FromResult<IReadOnlyList<FlowDefinitionDto>>([FlowEditorTests.Definition()]);
public Task<FlowDefinitionDto> GetAsync(int tenantId, Guid flowId, CancellationToken token = default) => Task.FromResult(FlowEditorTests.Definition());
Expand All @@ -170,5 +171,5 @@ public Task<FlowDefinitionDto> SaveAsync(int tenantId, Guid flowId, FlowSaveDraf
public Task<IReadOnlyList<FlowRunSummaryDto>> GetRunsAsync(int tenantId, Guid flowId, CancellationToken token = default) => Task.FromResult<IReadOnlyList<FlowRunSummaryDto>>([]);
public Task<FlowRunDetailDto> GetRunAsync(int tenantId, Guid flowId, Guid runId, CancellationToken token = default) => throw new NotSupportedException();
public Task<FlowVersionDto> GetVersionAsync(int tenantId, Guid versionId, CancellationToken token = default) => VersionLookup?.Invoke(tenantId, versionId, token) ?? throw new NotSupportedException();
public Task<FlowRunDetailDto> GetRunByIdAsync(int tenantId, Guid runId, CancellationToken token = default) => throw new NotSupportedException();
public Task<FlowRunDetailDto> GetRunByIdAsync(int tenantId, Guid runId, CancellationToken token = default) => RunLookup?.Invoke(tenantId, runId, token) ?? throw new NotSupportedException();
}
30 changes: 30 additions & 0 deletions src/NetRatel/NetRatel.Web.ComponentTests/FlowsPageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,36 @@ public async Task Unauthorized_Result_Query_Clears_The_Previous_Tenant_Viewer()
lookups.Should().Be(1);
}

[Theory]
[InlineData("NR-2026-000123", "https://fixture.invalid/incidents/123", true)]
[InlineData(null, "javascript:alert(1)", false)]
public async Task Run_Receipt_Separates_Code_Identity_Tracking_And_The_Optional_Safe_Link(string? tracking, string link, bool hasLink)
{
var version = Version(1, "Receipt graph"); var definition = FlowEditorTests.Definition(); var runId = Guid.NewGuid();
var run = new FlowRunDetailDto(new(runId, definition.Id, version.Id, Guid.NewGuid(), Guid.NewGuid(), FlowRunStatus.Succeeded, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "incident-created"),
new(Guid.NewGuid(), Guid.NewGuid(), "CPU rule", "SQL server", "cpu", "cpu.usage.percent", "warning", 96, null, DateTimeOffset.UtcNow),
[new(Guid.NewGuid(), "action", FlowActionStatus.Succeeded, 1, 1, "incident-created", new("123", tracking, link))]);
_api.RunLookup = (tenant, id, _) => { tenant.Should().Be(17); id.Should().Be(runId); return Task.FromResult(run); };
_api.VersionLookup = (_, _, _) => Task.FromResult(version);
Services.GetRequiredService<NavigationManager>().NavigateTo($"/flows?tenantId=17&run={runId:D}");
var cut = Render<FlowsPage>();
await cut.WaitForAssertionAsync(() => cut.Find("[data-testid=flow-action-result]").TextContent.Should().Contain("Status: Succeeded · Attempts: 1"));
var result = cut.Find("[data-testid=flow-action-result]");
result.QuerySelector("[data-testid=flow-action-code]")!.TextContent.Should().Be("incident-created");
result.QuerySelector("[data-testid=flow-incident-id]")!.TextContent.Should().Be("123");
result.QuerySelectorAll("dt").Select(e => e.TextContent).Should().Contain("Incident ID");
if (tracking is not null)
{
result.QuerySelectorAll("dt").Select(e => e.TextContent).Should().Contain("Tracking number");
result.QuerySelector("[data-testid=flow-incident-tracking]")!.TextContent.Should().Be(tracking);
}
else result.QuerySelector("[data-testid=flow-incident-tracking]").Should().BeNull();
var anchor = result.QuerySelector("a");
if (hasLink) { anchor!.GetAttribute("href").Should().Be(link); anchor.GetAttribute("rel").Should().Be("noopener noreferrer"); }
else anchor.Should().BeNull();
_api.SaveCalls.Should().Be(0); _api.PublishCalls.Should().Be(0);
}

private static FlowVersionDto Version(int number, string triggerName)
{
var definition = FlowEditorTests.Definition();
Expand Down
2 changes: 2 additions & 0 deletions src/NetRatel/NetRatel.Web.ComponentTests/MainLayoutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
using NetRatel.Web.Services.Access;
using NetRatel.Web.Services.Branding;
using NetRatel.Web.Services.Flows;
using NetRatel.Web.Services.Monitoring;
using Xunit;

namespace NetRatel.Web.ComponentTests;
Expand All @@ -39,6 +40,7 @@ public MainLayoutTests()
Services.AddSingleton<IAccessAdministrationApiService, StubAccessAdministrationApiService>();
Services.AddSingleton<IDeploymentBrandingApiService, StubDeploymentBrandingApiService>();
Services.AddSingleton<IFlowApiService, FakeFlowApi>();
Services.AddSingleton<IMonitoringApiService, MonitoringTestApi>();

AddAuthorization();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;
using NetRatel.Shared.Contracts.Monitoring;
using NetRatel.Web.Services.Authentication;
using NetRatel.Web.Services.Monitoring;
using Xunit;

namespace NetRatel.Web.ComponentTests;

public sealed class MonitoringApiTransportTests
{
[Theory]
[InlineData("rule", false)]
[InlineData("rule", true)]
[InlineData("clear", false)]
[InlineData("clear", true)]
public async Task LostResponseAndUnavailableWritesAreNeverReplayed(string operation, bool unavailable)
{
var wire = new RecordingHandler(_ => unavailable
? new(HttpStatusCode.ServiceUnavailable)
: throw new HttpRequestException("Response lost after the server accepted the write"));
using var services = Services(wire); using var scope = services.CreateScope();
Bind(scope, "alice", "fixture-alice");
var api = scope.ServiceProvider.GetRequiredService<IMonitoringApiService>();
var rule = Rule();
await Assert.ThrowsAsync<HttpRequestException>(() => operation == "rule"
? api.SaveRuleAsync(1, new(rule, 1, "explicit rule configuration"))
: api.ClearAsync(1, Series(rule), "validated manually"));
var request = Assert.Single(wire.Requests);
Assert.Equal(operation == "rule" ? HttpMethod.Put : HttpMethod.Post, request.Method);
Assert.Contains(operation == "rule" ? "/rules/" : "/clear?resourceKey=cpu", request.Uri);
}

[Fact]
public async Task RedirectedWriteIsNotReissuedWithOperatorCredentials()
{
var wire = new RecordingHandler(_ => new(HttpStatusCode.TemporaryRedirect)
{ Headers = { Location = new("https://other.example/redirected-write") } });
using var services = Services(wire); using var scope = services.CreateScope();
Bind(scope, "alice", "fixture-alice");
await Assert.ThrowsAsync<HttpRequestException>(() => scope.ServiceProvider.GetRequiredService<IMonitoringApiService>()
.SaveRuleAsync(1, new(Rule(), 1, "explicit rule configuration")));
var request = Assert.Single(wire.Requests);
Assert.Equal("NetRatel.Local=fixture-alice", request.Cookie);
Assert.StartsWith("https://fixture.example/", request.Uri);
}

[Fact]
public async Task SavedResponseRetainsPendingStatusAndExactCommittedRevision()
{
var saved = new MonitoringConfigurationDto(1, 2, [], [], [], DateTimeOffset.UtcNow, WatchPolicyUpdatePending: true);
var wire = new RecordingHandler(_ => new(HttpStatusCode.OK) { Content = JsonContent.Create(saved) });
using var services = Services(wire); using var scope = services.CreateScope();
Bind(scope, "alice", "fixture-alice");
var response = await scope.ServiceProvider.GetRequiredService<IMonitoringApiService>()
.SaveRuleAsync(1, new(Rule(), 1, "explicit configuration"));
Assert.Equal((ulong)2, response.Revision); Assert.Equal(1, response.TenantId); Assert.True(response.WatchPolicyUpdatePending);
Assert.Single(wire.Requests);
}

[Fact]
public async Task PooledMonitoringPipelineKeepsDistinctCircuitCredentialsAndRejectsAnonymousScope()
{
var wire = new RecordingHandler(_ => new(HttpStatusCode.OK)
{ Content = JsonContent.Create(new MonitoringConfigurationDto(1, 1, [], [], [], DateTimeOffset.UtcNow)) });
using var services = Services(wire);
using var alice = services.CreateScope(); using var bob = services.CreateScope(); using var anonymous = services.CreateScope();
Bind(alice, "alice", "fixture-alice"); Bind(bob, "bob", "fixture-bob");
var aliceApi = alice.ServiceProvider.GetRequiredService<IMonitoringApiService>();
var bobApi = bob.ServiceProvider.GetRequiredService<IMonitoringApiService>();
await Task.WhenAll(aliceApi.GetConfigurationAsync(1), bobApi.GetConfigurationAsync(1));
await aliceApi.GetConfigurationAsync(1);
await Assert.ThrowsAsync<ReauthRequiredException>(() => anonymous.ServiceProvider.GetRequiredService<IMonitoringApiService>().GetConfigurationAsync(1));
Assert.Equal(3, wire.Requests.Count);
Assert.Equal(2, wire.Requests.Count(request => request.Cookie == "NetRatel.Local=fixture-alice"));
Assert.Single(wire.Requests, request => request.Cookie == "NetRatel.Local=fixture-bob");
Assert.All(wire.Requests, request => Assert.Null(request.Authorization));
}

private static ServiceProvider Services(RecordingHandler wire)
{
var services = new ServiceCollection(); services.AddLogging(); services.AddHttpContextAccessor();
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["ApiBaseUrl"] = "https://fixture.example/" }).Build();
services.AddSingleton<IConfiguration>(configuration); services.AddScoped<OperatorApiCredentialState>();
services.AddScoped<OperatorApiCredentialProvider>(); services.AddScoped<ITokenService>(_ => new NoTokenService());
services.AddTransient<TokenAuthorizationHandler>();
// Exercise the same global default that Monitoring must explicitly remove.
services.ConfigureHttpClientDefaults(client => client.AddStandardResilienceHandler());
services.AddMonitoringApiClient(configuration);
services.AddHttpClient(MonitoringApiService.ClientName).ConfigurePrimaryHttpMessageHandler(() => wire);
services.AddScoped<IHttpClientFactory, OperatorApiHttpClientFactory>();
services.AddScoped<IMonitoringApiService, MonitoringApiService>();
return services.BuildServiceProvider(validateScopes: true);
}
private static void Bind(IServiceScope scope, string subject, string cookie)
{
var principal = new ClaimsPrincipal(new ClaimsIdentity([new(ClaimTypes.NameIdentifier, subject), new("auth_mode", "local")], "local"));
var state = scope.ServiceProvider.GetRequiredService<OperatorApiCredentialState>(); state.BindPrincipal(principal);
Assert.True(state.SetCredential(principal, new(OperatorApiCredentialKind.LocalSessionCookie, cookie, CookieName: "NetRatel.Local")));
}
private static MonitoringRuleDto Rule()
{
var draft = MonitoringRuleDraft.Create(); draft.Name = "CPU"; draft.AgentIds.Add(Guid.NewGuid()); return draft.Build(1);
}
private static MonitoringSeriesState Series(MonitoringRuleDto rule)
{
var now = DateTimeOffset.UtcNow;
var evidence = new MonitoringEvidenceDto(new(1, 1), Guid.NewGuid(), now, now, MonitoringEvidenceQuality.Fresh, MonitoringClassification.Breach, 95, 0, null);
return new(new(1, rule.RuleId, rule.Targets.AgentIds[0], "cpu"), 1, 1, MonitoringPhase.Firing, MonitoringEvidenceQuality.Fresh,
Occurrence: new(Guid.NewGuid(), Guid.NewGuid(), now, now.AddMinutes(-1), rule, evidence, MonitoringFlowDispatchDisposition.NoFlowSelected));
}
private sealed class NoTokenService : ITokenService
{
public Task<string> GetValidAccessTokenAsync() => throw new InvalidOperationException("A local circuit must not borrow any bearer or system credential.");
}
private sealed record Request(HttpMethod Method, string Uri, string? Cookie, string? Authorization);
private sealed class RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
public ConcurrentQueue<Request> Requests { get; } = new();
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Enqueue(new(request.Method, request.RequestUri!.AbsoluteUri,
request.Headers.TryGetValues("Cookie", out var cookies) ? string.Join("; ", cookies) : null, request.Headers.Authorization?.ToString()));
return Task.FromResult(respond(request));
}
}
}
Loading
Loading