| undefined)?.label as string) || '';
@@ -1061,13 +1070,23 @@ function WorkflowEditorInner() {
);
const updateGroupNode = useCallback((nodeId: string, updater: (node: Node) => Node) => {
+ if (!canWrite) return;
commitHistory('Edit group');
markDirty();
setNodes((nds: Node[]) => nds.map((n) => n.id === nodeId ? updater(n) : n));
- }, [commitHistory, setNodes, markDirty]);
- const groupNodeEditContextValue = useMemo(
- () => ({ updateGroupNode }),
- [updateGroupNode],
+ }, [canWrite, commitHistory, setNodes, markDirty]);
+ const toggleGroupView = useCallback((nodeId: string) => {
+ const group = nodes.find((node) => node.id === nodeId && node.type === 'group');
+ if (canWrite || !group) return;
+ setGroupCollapseOverrides((previous) => {
+ const next = new Map(previous);
+ next.set(nodeId, !(previous.get(nodeId) ?? group.data.collapsed === true));
+ return next;
+ });
+ }, [canWrite, nodes]);
+ const annotationEditContextValue = useMemo(
+ () => ({ canWrite, updateGroupNode, toggleGroupView }),
+ [canWrite, updateGroupNode, toggleGroupView],
);
// Resolved here rather than inside an IIFE in the JSX below. The `(() => { ... })()` form
@@ -1172,7 +1191,7 @@ function WorkflowEditorInner() {
>
{premiumCanvas && }
-
+
-
+
{!fullscreen && workflow && (
diff --git a/tests/NodePilot.Api.Tests/Security/ResourceAuthorizationSnapshotTests.cs b/tests/NodePilot.Api.Tests/Security/ResourceAuthorizationSnapshotTests.cs
new file mode 100644
index 00000000..07442369
--- /dev/null
+++ b/tests/NodePilot.Api.Tests/Security/ResourceAuthorizationSnapshotTests.cs
@@ -0,0 +1,239 @@
+using System.Data.Common;
+using System.Diagnostics;
+using System.Security.Claims;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using NodePilot.Api.Controllers;
+using NodePilot.Api.Dtos;
+using NodePilot.Api.Security;
+using NodePilot.Api.Tests.TestSupport;
+using NodePilot.Core.Enums;
+using NodePilot.Core.Interfaces;
+using NodePilot.Core.Models;
+using NodePilot.Data;
+using NodePilot.TestCommons;
+using Xunit;
+
+namespace NodePilot.Api.Tests.Security;
+
+public sealed class ResourceAuthorizationSnapshotTests(ITestOutputHelper output)
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(100)]
+ public async Task FolderList_ReadCountDoesNotGrowWithVisibleFolders(int visibleFolders)
+ {
+ var (connection, seed) = TestDbFactory.CreateWithConnection();
+ await using var ownedConnection = connection;
+ await using var ownedSeed = seed;
+ var user = LocalUser("folder-list-operator");
+ var parent = Folder("Visible", SharedWorkflowFolder.RootFolderId);
+ seed.AddRange(user, parent, UserGrant(parent.Id, user.Id, SharedFolderRole.FolderEditor));
+ for (var i = 1; i < visibleFolders; i++)
+ seed.Add(Folder($"Child-{i}", parent.Id, depth: 2));
+ seed.Add(Folder("Hidden", SharedWorkflowFolder.RootFolderId));
+ await seed.SaveChangesAsync(Ct);
+
+ var counter = new ReadCounter();
+ await using var db = CountingContext(connection, counter);
+ async Task ReadList()
+ {
+ var controller = new SharedWorkflowFoldersController(db, NoopAuditWriter.Instance,
+ new ResourceAuthorizationService(db), new RecordingHubContext(), new RecordingFolderProjection())
+ {
+ ControllerContext = new ControllerContext
+ {
+ HttpContext = new DefaultHttpContext { User = Principal(user.Id) },
+ },
+ };
+ var result = await controller.GetAll(Ct);
+ var folders = result.Result.Should().BeOfType().Subject.Value
+ .Should().BeOfType>().Subject;
+ folders.Should().HaveCount(visibleFolders);
+ folders.Should().OnlyContain(folder => folder.Capabilities.CanRead && folder.Capabilities.CanEdit);
+ }
+
+ await ReadList(); // Warm EF query compilation before comparing request costs.
+ var elapsed = new List();
+ for (var iteration = 0; iteration < 5; iteration++)
+ {
+ counter.Reset();
+ var timer = Stopwatch.StartNew();
+ await ReadList();
+ elapsed.Add(timer.Elapsed.TotalMilliseconds);
+ }
+
+ output.WriteLine("Folders={0}; reads={1}; grants={2}; users={3}; median_ms={4:F3}",
+ visibleFolders, counter.Reads, counter.Grants, counter.Users, elapsed.Order().ElementAt(2));
+ counter.Grants.Should().Be(1, "visibility and every capability must share the principal's grant snapshot");
+ counter.Users.Should().Be(1, "directory identity is loaded once per request");
+ counter.Reads.Should().BeLessThanOrEqualTo(5, "listing must not add a database read per visible folder");
+ }
+
+ [Fact]
+ public async Task CapabilitiesBeforeVisibility_ReuseTheSameGrantRead()
+ {
+ var (connection, seed) = TestDbFactory.CreateWithConnection();
+ await using var ownedConnection = connection;
+ await using var ownedSeed = seed;
+ var user = LocalUser("capabilities-first");
+ var parent = Folder("Parent", SharedWorkflowFolder.RootFolderId);
+ var child = Folder("Child", parent.Id, depth: 2);
+ seed.AddRange(user, parent, child, UserGrant(parent.Id, user.Id, SharedFolderRole.FolderEditor));
+ await seed.SaveChangesAsync(Ct);
+ var counter = new ReadCounter();
+ await using var db = CountingContext(connection, counter);
+ var service = new ResourceAuthorizationService(db);
+ var principal = Principal(user.Id);
+
+ (await service.GetWorkflowCapabilitiesAsync(principal, child.Id, Ct)).CanEdit.Should().BeTrue();
+ (await service.GetAccessibleFolderIdsAsync(principal, Ct)).FolderIds.Should().BeEquivalentTo([parent.Id, child.Id]);
+ (await service.GetFolderCapabilitiesAsync(principal, parent.Id, Ct)).CanEdit.Should().BeTrue();
+ (await service.GetWorkflowCapabilitiesAsync(principal, Guid.NewGuid(), Ct)).Should().Be(ResourceCapabilities.None);
+
+ counter.Grants.Should().Be(1);
+ counter.Reads.Should().Be(3);
+ }
+
+ [Fact]
+ public async Task Snapshot_IsPrincipalSpecific_AndGlobalCapsAreAppliedPerCall()
+ {
+ await using var db = TestDbFactory.Create();
+ var alice = LocalUser("alice");
+ var bob = LocalUser("bob");
+ var finance = Folder("Finance", SharedWorkflowFolder.RootFolderId);
+ var sales = Folder("Sales", SharedWorkflowFolder.RootFolderId);
+ db.AddRange(alice, bob, finance, sales,
+ UserGrant(finance.Id, alice.Id, SharedFolderRole.FolderAdmin),
+ UserGrant(sales.Id, bob.Id, SharedFolderRole.FolderViewer));
+ await db.SaveChangesAsync(Ct);
+ var service = new ResourceAuthorizationService(db);
+
+ (await service.GetAccessibleFolderIdsAsync(Principal(alice.Id), Ct)).FolderIds.Should().BeEquivalentTo([finance.Id]);
+ (await service.GetAccessibleFolderIdsAsync(Principal(bob.Id), Ct)).FolderIds.Should().BeEquivalentTo([sales.Id]);
+ (await service.GetWorkflowCapabilitiesAsync(Principal(bob.Id), finance.Id, Ct)).Should().Be(ResourceCapabilities.None);
+ (await service.GetWorkflowCapabilitiesAsync(Principal(alice.Id), finance.Id, Ct))
+ .Should().Be(new ResourceCapabilities(true, true, true, false, false));
+ (await service.GetWorkflowCapabilitiesAsync(Principal(alice.Id, "Viewer"), finance.Id, Ct))
+ .Should().Be(new ResourceCapabilities(true, false, false, false, false));
+ (await service.GetWorkflowCapabilitiesAsync(Principal(bob.Id, "Admin"), Guid.NewGuid(), Ct))
+ .Should().Be(ResourceCapabilities.All);
+ }
+
+ [Fact]
+ public async Task InvalidateAll_RefreshesVisibilityCapabilitiesAndAncestryAfterMutation()
+ {
+ await using var db = TestDbFactory.Create();
+ var user = LocalUser("mutation");
+ var parent = Folder("Granted", SharedWorkflowFolder.RootFolderId);
+ var child = Folder("Child", parent.Id, depth: 2);
+ var grant = UserGrant(parent.Id, user.Id, SharedFolderRole.FolderEditor);
+ db.AddRange(user, parent, child, grant);
+ await db.SaveChangesAsync(Ct);
+ var service = new ResourceAuthorizationService(db);
+ var principal = Principal(user.Id);
+ (await service.GetAccessibleFolderIdsAsync(principal, Ct)).FolderIds.Should().Contain(child.Id);
+ (await service.GetWorkflowCapabilitiesAsync(principal, child.Id, Ct)).CanEdit.Should().BeTrue();
+
+ using (await FolderTreeMutationLock.SharedWorkflowFolders.BeginMutationAsync(Ct))
+ {
+ child.ParentFolderId = SharedWorkflowFolder.RootFolderId;
+ child.Depth = 1;
+ await db.SaveChangesAsync(Ct);
+ service.InvalidateAll();
+ (await service.GetAccessibleFolderIdsAsync(principal, Ct)).FolderIds.Should().NotContain(child.Id);
+ (await service.GetWorkflowCapabilitiesAsync(principal, child.Id, Ct)).Should().Be(ResourceCapabilities.None);
+ }
+
+ db.Remove(grant);
+ await db.SaveChangesAsync(Ct);
+ service.InvalidateAll();
+ (await service.GetAccessibleFolderIdsAsync(principal, Ct)).FolderIds.Should().BeEmpty();
+ (await service.GetWorkflowCapabilitiesAsync(principal, parent.Id, Ct)).Should().Be(ResourceCapabilities.None);
+ }
+
+ [Fact]
+ public async Task GroupSnapshot_PreservesAuthorityPairs_AndRefreshesMembershipAfterInvalidation()
+ {
+ await using var db = TestDbFactory.Create();
+ const string issuerA = "https://idp.example.test/a";
+ const string issuerB = "https://idp.example.test/b";
+ var user = LocalUser("oidc");
+ user.Provider = AuthProvider.Oidc;
+ var allowed = Folder("Allowed", SharedWorkflowFolder.RootFolderId);
+ var crossed = Folder("Crossed", SharedWorkflowFolder.RootFolderId);
+ var membership = new DirectoryMembership
+ {
+ UserId = user.Id, Authority = issuerA, GroupKey = "operators", LastSeenAt = DateTime.UtcNow,
+ };
+ db.AddRange(user, allowed, crossed, membership,
+ new ExternalIdentity { Id = Guid.NewGuid(), UserId = user.Id, Authority = issuerA, Subject = "a" },
+ new ExternalIdentity { Id = Guid.NewGuid(), UserId = user.Id, Authority = issuerB, Subject = "b" },
+ new DirectoryMembership { UserId = user.Id, Authority = issuerB, GroupKey = "finance", LastSeenAt = DateTime.UtcNow },
+ GroupGrant(allowed.Id, issuerA, "operators"),
+ GroupGrant(crossed.Id, issuerB, "operators"));
+ await db.SaveChangesAsync(Ct);
+ var service = new ResourceAuthorizationService(db);
+ var principal = Principal(user.Id);
+
+ (await service.GetAccessibleFolderIdsAsync(principal, Ct)).FolderIds.Should().BeEquivalentTo([allowed.Id]);
+ (await service.GetWorkflowCapabilitiesAsync(principal, allowed.Id, Ct)).CanRun.Should().BeTrue();
+ (await service.GetWorkflowCapabilitiesAsync(principal, crossed.Id, Ct)).Should().Be(ResourceCapabilities.None);
+
+ db.Remove(membership);
+ await db.SaveChangesAsync(Ct);
+ service.InvalidateAll();
+ (await service.GetAccessibleFolderIdsAsync(principal, Ct)).FolderIds.Should().BeEmpty();
+ (await service.GetWorkflowCapabilitiesAsync(principal, allowed.Id, Ct)).Should().Be(ResourceCapabilities.None);
+ }
+
+ private static NodePilotDbContext CountingContext(Microsoft.Data.Sqlite.SqliteConnection connection, ReadCounter counter)
+ => new(new DbContextOptionsBuilder().UseSqlite(connection).AddInterceptors(counter).Options);
+
+ private static User LocalUser(string name) => new()
+ {
+ Id = Guid.NewGuid(), Username = name, Provider = AuthProvider.Local, Role = UserRole.Operator, IsActive = true,
+ };
+
+ private static SharedWorkflowFolder Folder(string name, Guid parentId, int depth = 1) => new()
+ {
+ Id = Guid.NewGuid(), Name = name, ParentFolderId = parentId, Path = "/" + name, Depth = depth,
+ };
+
+ private static SharedFolderPermission UserGrant(Guid folderId, Guid userId, SharedFolderRole role) => new()
+ {
+ Id = Guid.NewGuid(), FolderId = folderId, PrincipalType = FolderPrincipalType.User,
+ PrincipalKey = userId.ToString("D"), Role = role,
+ };
+
+ private static SharedFolderPermission GroupGrant(Guid folderId, string authority, string key) => new()
+ {
+ Id = Guid.NewGuid(), FolderId = folderId, PrincipalType = FolderPrincipalType.Group,
+ PrincipalAuthority = authority, PrincipalKey = key, Role = SharedFolderRole.FolderOperator,
+ };
+
+ private static ClaimsPrincipal Principal(Guid userId, string role = "Operator") => new(new ClaimsIdentity(
+ [new Claim(ClaimTypes.NameIdentifier, userId.ToString()), new Claim(ClaimTypes.Role, role)], "test"));
+
+ private sealed class ReadCounter : DbCommandInterceptor
+ {
+ public int Reads { get; private set; }
+ public int Grants { get; private set; }
+ public int Users { get; private set; }
+ public void Reset() => Reads = Grants = Users = 0;
+
+ public override ValueTask> ReaderExecutingAsync(DbCommand command, CommandEventData eventData,
+ InterceptionResult result, CancellationToken cancellationToken = default)
+ {
+ Reads++;
+ if (command.CommandText.Contains("FROM \"SharedFolderPermissions\"", StringComparison.Ordinal)) Grants++;
+ if (command.CommandText.Contains("FROM \"Users\"", StringComparison.Ordinal)) Users++;
+ return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
+ }
+ }
+}
diff --git a/tests/NodePilot.Api.Tests/Telemetry/OtlpTestCollector.cs b/tests/NodePilot.Api.Tests/Telemetry/OtlpTestCollector.cs
new file mode 100644
index 00000000..c8dc8d96
--- /dev/null
+++ b/tests/NodePilot.Api.Tests/Telemetry/OtlpTestCollector.cs
@@ -0,0 +1,84 @@
+using System.Collections.Concurrent;
+using System.Net;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Server.Kestrel.Core;
+using Microsoft.Extensions.Logging;
+using Xunit;
+
+namespace NodePilot.Api.Tests.Telemetry;
+
+internal sealed class OtlpTestCollector(WebApplication app) : IAsyncDisposable
+{
+ private readonly TaskCompletionSource _firstRequest = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public string Endpoint => app.Urls.Single();
+ public ConcurrentQueue Requests { get; } = new();
+
+ public static async Task StartAsync(HttpProtocols protocol)
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.Logging.ClearProviders();
+ builder.WebHost.ConfigureKestrel(options =>
+ options.Listen(IPAddress.Loopback, 0, listen => listen.Protocols = protocol));
+ var app = builder.Build();
+ var collector = new OtlpTestCollector(app);
+ app.Run(collector.ReceiveAsync);
+ try
+ {
+ using var startup = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ startup.CancelAfter(TimeSpan.FromSeconds(10));
+ await app.StartAsync(startup.Token);
+ return collector;
+ }
+ catch
+ {
+ await collector.DisposeAsync();
+ throw;
+ }
+ }
+
+ public Task WaitForRequestAsync() =>
+ _firstRequest.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+
+ private async Task ReceiveAsync(HttpContext context)
+ {
+ using var body = new MemoryStream();
+ await context.Request.Body.CopyToAsync(body, context.RequestAborted);
+ var request = new ExportRequest(
+ context.Request.Method,
+ context.Request.Path.Value ?? "",
+ context.Request.Protocol,
+ context.Request.ContentType ?? "",
+ context.Request.Headers.ToDictionary(pair => pair.Key, pair => pair.Value.ToString(), StringComparer.OrdinalIgnoreCase),
+ body.ToArray());
+ Requests.Enqueue(request);
+ _firstRequest.TrySetResult(request);
+
+ if (request.ContentType.StartsWith("application/grpc", StringComparison.Ordinal))
+ {
+ context.Response.ContentType = "application/grpc";
+ context.Response.DeclareTrailer("grpc-status");
+ // An empty ExportLogsServiceResponse in an uncompressed gRPC frame.
+ await context.Response.Body.WriteAsync(new byte[5], context.RequestAborted);
+ context.Response.AppendTrailer("grpc-status", "0");
+ }
+ else
+ {
+ context.Response.ContentType = "application/x-protobuf";
+ context.Response.ContentLength = 0;
+ }
+ }
+
+ public async ValueTask DisposeAsync() =>
+ await app.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+
+ internal sealed record ExportRequest(
+ string Method,
+ string Path,
+ string Protocol,
+ string ContentType,
+ Dictionary Headers,
+ byte[] Body);
+}
diff --git a/tests/NodePilot.Api.Tests/Telemetry/SerilogTelemetryBridgeTests.cs b/tests/NodePilot.Api.Tests/Telemetry/SerilogTelemetryBridgeTests.cs
index f666346e..ca354ac1 100644
--- a/tests/NodePilot.Api.Tests/Telemetry/SerilogTelemetryBridgeTests.cs
+++ b/tests/NodePilot.Api.Tests/Telemetry/SerilogTelemetryBridgeTests.cs
@@ -1,6 +1,11 @@
+using System.Collections;
+using System.Buffers.Binary;
+using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using FluentAssertions;
+using Google.Protobuf;
+using Microsoft.AspNetCore.Server.Kestrel.Core;
using NodePilot.Telemetry;
using Serilog;
using Xunit;
@@ -8,14 +13,29 @@
namespace NodePilot.Api.Tests.Telemetry;
///
-/// Covers the Serilog-to-OpenTelemetry sink bridge. This wiring feeds the ECS/SIEM log
-/// pipeline, so a mistake here (wrong protocol switch, header parsing that throws on a
-/// malformed pair) silently drops production logs. Exercise every branch: the disabled
-/// no-op, the gRPC/HTTP protocol split, and the comma-separated header parser including a
-/// malformed entry that must be skipped rather than crash the host on boot.
+/// Verifies resource identity and actual log delivery through the configured OTLP transport.
///
-public class SerilogTelemetryBridgeTests
+[Collection(SerilogTelemetryBridgeCollection.Name)]
+public class SerilogTelemetryBridgeTests : IDisposable
{
+ private readonly Dictionary _otelEnvironment;
+
+ public SerilogTelemetryBridgeTests()
+ {
+ // The sink reads OTEL_* directly; these tests must never use an external collector.
+ _otelEnvironment = Environment.GetEnvironmentVariables().Cast()
+ .Where(entry => ((string)entry.Key).StartsWith("OTEL_", StringComparison.OrdinalIgnoreCase))
+ .ToDictionary(entry => (string)entry.Key, entry => (string?)entry.Value);
+ foreach (var key in _otelEnvironment.Keys)
+ Environment.SetEnvironmentVariable(key, null);
+ }
+
+ public void Dispose()
+ {
+ foreach (var (key, value) in _otelEnvironment)
+ Environment.SetEnvironmentVariable(key, value);
+ }
+
private static IConfiguration Config(Dictionary values) =>
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
@@ -88,36 +108,34 @@ public void CreateResourceAttributes_RedactionDisabled_PreservesExistingHostname
attributes.Should().NotContainKey("service.instance.id");
}
- [Fact]
- public void AddNodePilotOpenTelemetry_Disabled_ReturnsConfigUnchangedAndLogsWithoutOtel()
- {
- var cfg = new LoggerConfiguration();
- var result = cfg.AddNodePilotOpenTelemetry(
- Config(new() { ["OpenTelemetry:Enabled"] = "false" }), Env());
-
- result.Should().BeSameAs(cfg);
- using var logger = result.CreateLogger();
- logger.Information("no otel sink attached");
- }
-
- [Fact]
- public void AddNodePilotOpenTelemetry_EnabledButLogsExporterOff_IsNoOp()
+ [Theory]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ public async Task AddNodePilotOpenTelemetry_Disabled_DoesNotExport(bool enabled, bool logsEnabled)
{
+ await using var collector = await OtlpTestCollector.StartAsync(HttpProtocols.Http1);
var cfg = new LoggerConfiguration();
var result = cfg.AddNodePilotOpenTelemetry(
Config(new()
{
- ["OpenTelemetry:Enabled"] = "true",
- ["OpenTelemetry:Exporters:Logs"] = "false",
+ ["OpenTelemetry:Enabled"] = enabled.ToString(),
+ ["OpenTelemetry:Exporters:Logs"] = logsEnabled.ToString(),
+ ["OpenTelemetry:Otlp:Endpoint"] = collector.Endpoint,
+ ["OpenTelemetry:Otlp:Protocol"] = "http/protobuf",
}),
Env());
result.Should().BeSameAs(cfg);
+ await EmitAndFlushAsync(result, $"disabled-{Guid.NewGuid():N}");
+
+ collector.Requests.Should().BeEmpty();
}
[Fact]
- public void AddNodePilotOpenTelemetry_GrpcDefault_BuildsWorkingLogger()
+ public async Task AddNodePilotOpenTelemetry_GrpcDefault_ExportsLogRecord()
{
+ await using var collector = await OtlpTestCollector.StartAsync(HttpProtocols.Http2);
+ var marker = $"grpc-{Guid.NewGuid():N}";
var cfg = new LoggerConfiguration().AddNodePilotOpenTelemetry(
Config(new()
{
@@ -125,32 +143,94 @@ public void AddNodePilotOpenTelemetry_GrpcDefault_BuildsWorkingLogger()
["OpenTelemetry:Exporters:Logs"] = "true",
["OpenTelemetry:ServiceName"] = "nodepilot-test",
["OpenTelemetry:Environment"] = "staging",
- // no protocol set, so grpc default; no endpoint set, so localhost:4317 default
+ ["OpenTelemetry:Otlp:Endpoint"] = collector.Endpoint,
}),
Env());
- using var logger = cfg.CreateLogger();
- logger.Information("emitted via otel grpc sink");
+ await EmitAndFlushAsync(cfg, marker);
+
+ var request = await collector.WaitForRequestAsync();
+ request.Method.Should().Be("POST");
+ request.Path.Should().Be("/opentelemetry.proto.collector.logs.v1.LogsService/Export");
+ request.Protocol.Should().Be("HTTP/2");
+ request.ContentType.Should().StartWith("application/grpc");
+ request.Body.Length.Should().BeGreaterThan(5);
+ request.Body[0].Should().Be(0, "the gRPC frame must be uncompressed");
+ BinaryPrimitives.ReadInt32BigEndian(request.Body.AsSpan(1, 4)).Should().Be(request.Body.Length - 5);
+ LogBodies(request.Body[5..]).Should().ContainSingle().Which.Should().Be(marker);
}
[Theory]
[InlineData("http")]
[InlineData("http/protobuf")]
- public void AddNodePilotOpenTelemetry_HttpProtocolWithHeaders_ParsesHeadersAndBuilds(string protocol)
+ public async Task AddNodePilotOpenTelemetry_HttpProtocolWithHeaders_ExportsLogRecord(string protocol)
{
+ await using var collector = await OtlpTestCollector.StartAsync(HttpProtocols.Http1);
+ var marker = $"http-{Guid.NewGuid():N}";
var cfg = new LoggerConfiguration().AddNodePilotOpenTelemetry(
Config(new()
{
["OpenTelemetry:Enabled"] = "true",
["OpenTelemetry:Exporters:Logs"] = "true",
- ["OpenTelemetry:Otlp:Endpoint"] = "http://collector.example:4318",
+ ["OpenTelemetry:Otlp:Endpoint"] = collector.Endpoint,
["OpenTelemetry:Otlp:Protocol"] = protocol,
- // one well-formed header, one malformed pair with no '=' that must be skipped
- ["OpenTelemetry:Otlp:Headers"] = "x-api-key=secret123 , malformed-no-equals",
+ ["OpenTelemetry:Otlp:Headers"] = " x-api-key = synthetic-key , malformed-no-equals, x-tenant = team=one ",
}),
Env());
- using var logger = cfg.CreateLogger();
- logger.Information("emitted via otel http sink");
+ await EmitAndFlushAsync(cfg, marker);
+
+ var request = await collector.WaitForRequestAsync();
+ request.Method.Should().Be("POST");
+ request.Path.Should().Be("/v1/logs");
+ request.Protocol.Should().Be("HTTP/1.1");
+ request.ContentType.Should().Be("application/x-protobuf");
+ request.Headers.Should().Contain("x-api-key", "synthetic-key");
+ request.Headers.Should().Contain("x-tenant", "team=one");
+ request.Headers.Should().NotContainKey("malformed-no-equals");
+ LogBodies(request.Body).Should().ContainSingle().Which.Should().Be(marker);
}
+
+ private static async Task EmitAndFlushAsync(LoggerConfiguration config, string marker)
+ {
+ var logger = config.CreateLogger();
+ try
+ {
+ logger.Information("{Marker}", marker);
+ }
+ finally
+ {
+ await logger.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ }
+ }
+
+ private static IEnumerable LogBodies(byte[] payload)
+ {
+ // ExportLogsServiceRequest.resource_logs -> scope_logs -> log_records -> body -> string_value.
+ return MessageFields(payload, 1)
+ .SelectMany(resource => MessageFields(resource, 2))
+ .SelectMany(scope => MessageFields(scope, 2))
+ .SelectMany(record => MessageFields(record, 5))
+ .SelectMany(body => MessageFields(body, 1))
+ .Select(Encoding.UTF8.GetString);
+ }
+
+ private static IEnumerable MessageFields(byte[] payload, int fieldNumber)
+ {
+ using var input = new CodedInputStream(payload);
+ uint tag;
+ while ((tag = input.ReadTag()) != 0)
+ {
+ if (tag == WireFormat.MakeTag(fieldNumber, WireFormat.WireType.LengthDelimited))
+ yield return input.ReadBytes().ToByteArray();
+ else
+ input.SkipLastField();
+ }
+ }
+}
+
+[CollectionDefinition(Name, DisableParallelization = true)]
+public sealed class SerilogTelemetryBridgeCollection
+{
+ public const string Name = "Serilog telemetry environment";
}
diff --git a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientNewSurfaceTests.cs b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientNewSurfaceTests.cs
index 69558114..cdb1f239 100644
--- a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientNewSurfaceTests.cs
+++ b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientNewSurfaceTests.cs
@@ -439,7 +439,14 @@ public async Task MoveWorkflowToFolderAsync_Posts()
_server.Given(Request.Create().WithPath($"/api/workflows/{wf}/move-folder").UsingPost())
.RespondWith(Response.Create().WithStatusCode(204));
await _client.MoveWorkflowToFolderAsync(wf, new MoveWorkflowToFolderRequest(target), CancellationToken.None);
- // Reaching here without throwing means success.
+
+ var request = _server.LogEntries.Should().ContainSingle().Subject.RequestMessage;
+ Assert.NotNull(request);
+ request.Method.Should().Be("POST");
+ request.Path.Should().Be($"/api/workflows/{wf}/move-folder");
+ Assert.NotNull(request.Body);
+ using var body = JsonDocument.Parse(request.Body);
+ body.RootElement.GetProperty("targetFolderId").GetGuid().Should().Be(target);
}
// ---- Observability ------------------------------------------------------
diff --git a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientResourcesTests.cs b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientResourcesTests.cs
index 2534b0f5..393f256d 100644
--- a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientResourcesTests.cs
+++ b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientResourcesTests.cs
@@ -1,4 +1,5 @@
using System.Net;
+using System.Text.Json;
using FluentAssertions;
using NodePilot.Cli.Api;
using NodePilot.Cli.Api.Dtos;
@@ -253,9 +254,18 @@ public async Task DeleteGlobalVariableAsync_DeletesAndAccepts204()
public async Task MoveGlobalVariableToFolderAsync_PostsAndAccepts204()
{
var id = Guid.NewGuid();
+ var folderId = Guid.NewGuid();
_server.Given(Request.Create().WithPath($"/api/global-variables/{id}/move-folder").UsingPost())
.RespondWith(Response.Create().WithStatusCode(204));
- await _client.MoveGlobalVariableToFolderAsync(id, Guid.NewGuid(), CancellationToken.None);
+ await _client.MoveGlobalVariableToFolderAsync(id, folderId, CancellationToken.None);
+
+ var request = _server.LogEntries.Should().ContainSingle().Subject.RequestMessage;
+ Assert.NotNull(request);
+ request.Method.Should().Be("POST");
+ request.Path.Should().Be($"/api/global-variables/{id}/move-folder");
+ Assert.NotNull(request.Body);
+ using var body = JsonDocument.Parse(request.Body);
+ body.RootElement.GetProperty("folderId").GetGuid().Should().Be(folderId);
}
// ---- Global Variable Folders --------------------------------------------
@@ -298,6 +308,14 @@ public async Task RenameGlobalVariableFolderAsync_PutsAndAccepts204()
_server.Given(Request.Create().WithPath($"/api/global-variable-folders/{id}").UsingPut())
.RespondWith(Response.Create().WithStatusCode(204));
await _client.RenameGlobalVariableFolderAsync(id, new UpdateGlobalVariableFolderRequest("Renamed"), CancellationToken.None);
+
+ var request = _server.LogEntries.Should().ContainSingle().Subject.RequestMessage;
+ Assert.NotNull(request);
+ request.Method.Should().Be("PUT");
+ request.Path.Should().Be($"/api/global-variable-folders/{id}");
+ Assert.NotNull(request.Body);
+ using var body = JsonDocument.Parse(request.Body);
+ body.RootElement.GetProperty("name").GetString().Should().Be("Renamed");
}
[Fact]
@@ -307,6 +325,14 @@ public async Task MoveGlobalVariableFolderAsync_PostsAndAccepts204()
_server.Given(Request.Create().WithPath($"/api/global-variable-folders/{id}/move").UsingPost())
.RespondWith(Response.Create().WithStatusCode(204));
await _client.MoveGlobalVariableFolderAsync(id, new MoveGlobalVariableFolderRequest(null), CancellationToken.None);
+
+ var request = _server.LogEntries.Should().ContainSingle().Subject.RequestMessage;
+ Assert.NotNull(request);
+ request.Method.Should().Be("POST");
+ request.Path.Should().Be($"/api/global-variable-folders/{id}/move");
+ Assert.NotNull(request.Body);
+ using var body = JsonDocument.Parse(request.Body);
+ body.RootElement.GetProperty("newParentFolderId").ValueKind.Should().Be(JsonValueKind.Null);
}
[Fact]
@@ -316,6 +342,11 @@ public async Task DeleteGlobalVariableFolderAsync_DeletesAndAccepts204()
_server.Given(Request.Create().WithPath($"/api/global-variable-folders/{id}").UsingDelete())
.RespondWith(Response.Create().WithStatusCode(204));
await _client.DeleteGlobalVariableFolderAsync(id, CancellationToken.None);
+
+ var request = _server.LogEntries.Should().ContainSingle().Subject.RequestMessage;
+ Assert.NotNull(request);
+ request.Method.Should().Be("DELETE");
+ request.Path.Should().Be($"/api/global-variable-folders/{id}");
}
// ---- Users (Admin) ------------------------------------------------------
diff --git a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientTests.cs b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientTests.cs
index 77daa613..09911a37 100644
--- a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientTests.cs
+++ b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientTests.cs
@@ -249,6 +249,14 @@ public async Task SetWorkflowConcurrencyLimitAsync_PutsTheLimit()
_server.Given(Request.Create().WithPath($"/api/workflows/{id}/concurrency-limit").UsingPut())
.RespondWith(Response.Create().WithStatusCode(204));
await _client.SetWorkflowConcurrencyLimitAsync(id, 5, CancellationToken.None);
+
+ var request = _server.LogEntries.Should().ContainSingle().Subject.RequestMessage;
+ Assert.NotNull(request);
+ request.Method.Should().Be("PUT");
+ request.Path.Should().Be($"/api/workflows/{id}/concurrency-limit");
+ Assert.NotNull(request.Body);
+ using var body = JsonDocument.Parse(request.Body);
+ body.RootElement.GetProperty("maxConcurrentExecutions").GetInt32().Should().Be(5);
}
[Fact]
@@ -258,6 +266,14 @@ public async Task SetWorkflowConcurrencyLimitAsync_WithNull_ClearsTheLimit()
_server.Given(Request.Create().WithPath($"/api/workflows/{id}/concurrency-limit").UsingPut())
.RespondWith(Response.Create().WithStatusCode(204));
await _client.SetWorkflowConcurrencyLimitAsync(id, null, CancellationToken.None);
+
+ var request = _server.LogEntries.Should().ContainSingle().Subject.RequestMessage;
+ Assert.NotNull(request);
+ request.Method.Should().Be("PUT");
+ request.Path.Should().Be($"/api/workflows/{id}/concurrency-limit");
+ Assert.NotNull(request.Body);
+ using var body = JsonDocument.Parse(request.Body);
+ body.RootElement.GetProperty("maxConcurrentExecutions").ValueKind.Should().Be(JsonValueKind.Null);
}
[Fact]