mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into copilot/fix-flaky-workflow-test
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.6.0</VersionPrefix>
|
||||
<VersionPrefix>1.6.1</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260512</DateSuffix>
|
||||
<DateSuffix>260514</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.6.0</GitTag>
|
||||
<GitTag>1.6.1</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -122,6 +122,7 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(fullDir)
|
||||
.Where(f => (File.GetAttributes(f) & FileAttributes.ReparsePoint) == 0)
|
||||
.Select(Path.GetFileName)
|
||||
.Where(name => name is not null)
|
||||
.ToList();
|
||||
@@ -157,6 +158,12 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
|
||||
|
||||
foreach (string filePath in Directory.GetFiles(fullDir))
|
||||
{
|
||||
// Skip files that are symlinks/reparse points to prevent reading outside the root.
|
||||
if ((File.GetAttributes(filePath) & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? fileName = Path.GetFileName(filePath);
|
||||
if (fileName is null)
|
||||
{
|
||||
@@ -231,7 +238,7 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a relative file path to a safe absolute path under the root directory.
|
||||
/// Rejects paths that would escape the root via traversal or rooted paths.
|
||||
/// Rejects paths that would escape the root via traversal, rooted paths, or symbolic links.
|
||||
/// </summary>
|
||||
private string ResolveSafePath(string relativePath)
|
||||
{
|
||||
@@ -250,9 +257,55 @@ public sealed class FileSystemAgentFileStore : AgentFileStore
|
||||
nameof(relativePath));
|
||||
}
|
||||
|
||||
// Reject symlinks/reparse points in any path segment to prevent escaping the root.
|
||||
ThrowIfContainsSymlink(fullPath, this._rootPath);
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks each path segment between the trusted root and the resolved path for symbolic links
|
||||
/// or reparse points. Throws <see cref="ArgumentException"/> if any segment is a symlink.
|
||||
/// Stops checking at the first segment that does not exist on disk (for write scenarios).
|
||||
/// Uses <see cref="File.GetAttributes(string)"/> directly so that dangling symlinks (whose targets
|
||||
/// do not exist) are still detected via their <see cref="FileAttributes.ReparsePoint"/> flag.
|
||||
/// </summary>
|
||||
private static void ThrowIfContainsSymlink(string fullPath, string rootPath)
|
||||
{
|
||||
string rootTrimmed = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
string relative = fullPath.Substring(rootTrimmed.Length);
|
||||
string[] segments = relative.Split(
|
||||
[Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
|
||||
StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
string current = rootTrimmed;
|
||||
foreach (string segment in segments)
|
||||
{
|
||||
current = Path.Combine(current, segment);
|
||||
|
||||
FileAttributes attributes;
|
||||
try
|
||||
{
|
||||
attributes = File.GetAttributes(current);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
// Segment does not exist on disk (write scenario); stop checking.
|
||||
break;
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if ((attributes & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Invalid path: the resolved path contains a symbolic link or reparse point.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a relative directory path to a safe absolute path under the root directory.
|
||||
/// An empty string resolves to the root directory itself.
|
||||
|
||||
@@ -218,7 +218,7 @@ public class DevUIIntegrationTests
|
||||
Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-workflow" && e.Type == "workflow");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = "Flaky in merge_group; see https://github.com/microsoft/agent-framework/issues/5845")]
|
||||
public async Task TestServerWithDevUI_ResolvesMixedAgentsAndWorkflows_AllRegistrationsAsync()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
+471
@@ -334,4 +334,475 @@ public sealed class FileSystemAgentFileStoreTests : IDisposable
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Symlink Escape Rejection
|
||||
|
||||
#if NET
|
||||
/// <summary>
|
||||
/// Attempts to create a file symlink. Returns false if the platform does not support
|
||||
/// symlink creation (e.g., Windows without developer mode) or if creation fails.
|
||||
/// </summary>
|
||||
private static bool TryCreateFileSymbolicLink(string linkPath, string targetPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.CreateSymbolicLink(linkPath, targetPath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify the symlink was actually created as a reparse point.
|
||||
return File.Exists(linkPath)
|
||||
&& (File.GetAttributes(linkPath) & FileAttributes.ReparsePoint) != 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to create a directory symlink. Returns false if the platform does not support
|
||||
/// symlink creation (e.g., Windows without developer mode) or if creation fails.
|
||||
/// </summary>
|
||||
private static bool TryCreateDirectorySymbolicLink(string linkPath, string targetPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateSymbolicLink(linkPath, targetPath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify the symlink was actually created as a reparse point.
|
||||
return Directory.Exists(linkPath)
|
||||
&& (File.GetAttributes(linkPath) & FileAttributes.ReparsePoint) != 0;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadFileAsync_SymlinkedFile_ThrowsAsync()
|
||||
{
|
||||
// Arrange — create a file outside the root and symlink to it from inside.
|
||||
string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_target_read_" + Guid.NewGuid().ToString("N") + ".txt");
|
||||
File.WriteAllText(outsideFile, "SECRET_OUTSIDE_ROOT");
|
||||
|
||||
string linkPath = Path.Combine(this._rootDir, "leak.txt");
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryCreateFileSymbolicLink(linkPath, outsideFile))
|
||||
{
|
||||
return; // Cannot create symlinks in this environment; skip.
|
||||
}
|
||||
|
||||
// Act & Assert — reading through the symlink should be rejected.
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => this._store.ReadFileAsync("leak.txt"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(linkPath))
|
||||
{
|
||||
File.Delete(linkPath);
|
||||
}
|
||||
|
||||
File.Delete(outsideFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFileAsync_SymlinkedFile_ThrowsAsync()
|
||||
{
|
||||
// Arrange — create a file outside the root and symlink to it from inside.
|
||||
string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_target_write_" + Guid.NewGuid().ToString("N") + ".txt");
|
||||
File.WriteAllText(outsideFile, "ORIGINAL_CONTENT");
|
||||
|
||||
string linkPath = Path.Combine(this._rootDir, "overwrite.txt");
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryCreateFileSymbolicLink(linkPath, outsideFile))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Act & Assert — writing through the symlink should be rejected.
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => this._store.WriteFileAsync("overwrite.txt", "EVIL_CONTENT"));
|
||||
|
||||
// Verify the outside file was NOT modified.
|
||||
Assert.Equal("ORIGINAL_CONTENT", await File.ReadAllTextAsync(outsideFile));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(linkPath))
|
||||
{
|
||||
File.Delete(linkPath);
|
||||
}
|
||||
|
||||
File.Delete(outsideFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFileAsync_SymlinkedFile_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_target_delete_" + Guid.NewGuid().ToString("N") + ".txt");
|
||||
File.WriteAllText(outsideFile, "DO_NOT_DELETE");
|
||||
|
||||
string linkPath = Path.Combine(this._rootDir, "trap.txt");
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryCreateFileSymbolicLink(linkPath, outsideFile))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => this._store.DeleteFileAsync("trap.txt"));
|
||||
|
||||
// Verify the outside file still exists.
|
||||
Assert.True(File.Exists(outsideFile));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(linkPath))
|
||||
{
|
||||
File.Delete(linkPath);
|
||||
}
|
||||
|
||||
File.Delete(outsideFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileExistsAsync_SymlinkedFile_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_target_exists_" + Guid.NewGuid().ToString("N") + ".txt");
|
||||
File.WriteAllText(outsideFile, "EXISTS_OUTSIDE");
|
||||
|
||||
string linkPath = Path.Combine(this._rootDir, "phantom.txt");
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryCreateFileSymbolicLink(linkPath, outsideFile))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => this._store.FileExistsAsync("phantom.txt"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(linkPath))
|
||||
{
|
||||
File.Delete(linkPath);
|
||||
}
|
||||
|
||||
File.Delete(outsideFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFileAsync_DanglingSymlink_ThrowsAsync()
|
||||
{
|
||||
// Arrange — create a symlink pointing to a non-existent target.
|
||||
string nonExistentTarget = Path.Combine(Path.GetTempPath(), "dangling_target_" + Guid.NewGuid().ToString("N") + ".txt");
|
||||
string linkPath = Path.Combine(this._rootDir, "dangling.txt");
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryCreateFileSymbolicLink(linkPath, nonExistentTarget))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Act & Assert — even a dangling symlink must be rejected.
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => this._store.WriteFileAsync("dangling.txt", "CONTENT"));
|
||||
|
||||
// Verify the target was NOT created by following the dangling link.
|
||||
Assert.False(File.Exists(nonExistentTarget));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Dangling symlinks: File.Exists returns false, but the link entry still exists.
|
||||
// Use FileInfo to delete the link itself.
|
||||
var linkInfo = new FileInfo(linkPath);
|
||||
if (linkInfo.Exists || (linkInfo.Attributes & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
linkInfo.Delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFilesAsync_SymlinkedDirectory_ThrowsAsync()
|
||||
{
|
||||
// Arrange — create a directory outside root and symlink a directory inside root to it.
|
||||
string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_dir_target_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(outsideDir);
|
||||
File.WriteAllText(Path.Combine(outsideDir, "secret.txt"), "SECRET");
|
||||
|
||||
string linkDir = Path.Combine(this._rootDir, "linked-dir");
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => this._store.ListFilesAsync("linked-dir"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(linkDir))
|
||||
{
|
||||
Directory.Delete(linkDir);
|
||||
}
|
||||
|
||||
Directory.Delete(outsideDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFilesAsync_SymlinkedDirectory_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_search_target_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(outsideDir);
|
||||
File.WriteAllText(Path.Combine(outsideDir, "data.txt"), "SENSITIVE_DATA");
|
||||
|
||||
string linkDir = Path.Combine(this._rootDir, "search-link");
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => this._store.SearchFilesAsync("search-link", "SENSITIVE"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(linkDir))
|
||||
{
|
||||
Directory.Delete(linkDir);
|
||||
}
|
||||
|
||||
Directory.Delete(outsideDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadFileAsync_ThroughDirectorySymlink_ThrowsAsync()
|
||||
{
|
||||
// Arrange — directory symlink inside root pointing outside; read a file through it.
|
||||
string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_dir_read_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(outsideDir);
|
||||
File.WriteAllText(Path.Combine(outsideDir, "secret.txt"), "DIR_SYMLINK_SECRET");
|
||||
|
||||
string linkDir = Path.Combine(this._rootDir, "linked-output");
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Act & Assert — reading through a directory symlink should be rejected.
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => this._store.ReadFileAsync("linked-output/secret.txt"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(linkDir))
|
||||
{
|
||||
Directory.Delete(linkDir);
|
||||
}
|
||||
|
||||
Directory.Delete(outsideDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFileAsync_ThroughDirectorySymlink_ThrowsAsync()
|
||||
{
|
||||
// Arrange — directory symlink; attempt to create/overwrite a file through it.
|
||||
string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_dir_write_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(outsideDir);
|
||||
|
||||
string linkDir = Path.Combine(this._rootDir, "linked-output");
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => this._store.WriteFileAsync("linked-output/created-by-agent.txt", "CONTENT"));
|
||||
|
||||
// Verify no file was created outside.
|
||||
Assert.False(File.Exists(Path.Combine(outsideDir, "created-by-agent.txt")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(linkDir))
|
||||
{
|
||||
Directory.Delete(linkDir);
|
||||
}
|
||||
|
||||
Directory.Delete(outsideDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFileAsync_ThroughDirectorySymlink_ThrowsAsync()
|
||||
{
|
||||
// Arrange — directory symlink; attempt to delete a file through it.
|
||||
string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_dir_delete_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(outsideDir);
|
||||
string outsideFile = Path.Combine(outsideDir, "delete-me.txt");
|
||||
File.WriteAllText(outsideFile, "DO_NOT_DELETE");
|
||||
|
||||
string linkDir = Path.Combine(this._rootDir, "linked-output");
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => this._store.DeleteFileAsync("linked-output/delete-me.txt"));
|
||||
|
||||
// Verify the outside file was NOT deleted.
|
||||
Assert.True(File.Exists(outsideFile));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(linkDir))
|
||||
{
|
||||
Directory.Delete(linkDir);
|
||||
}
|
||||
|
||||
Directory.Delete(outsideDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDirectoryAsync_ThroughDirectorySymlink_ThrowsAsync()
|
||||
{
|
||||
// Arrange — directory symlink; attempt to create a subdirectory through it.
|
||||
string outsideDir = Path.Combine(Path.GetTempPath(), "symlink_dir_mkdir_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(outsideDir);
|
||||
|
||||
string linkDir = Path.Combine(this._rootDir, "linked-output");
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryCreateDirectorySymbolicLink(linkDir, outsideDir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => this._store.CreateDirectoryAsync("linked-output/created-directory"));
|
||||
|
||||
// Verify no directory was created outside.
|
||||
Assert.False(Directory.Exists(Path.Combine(outsideDir, "created-directory")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(linkDir))
|
||||
{
|
||||
Directory.Delete(linkDir);
|
||||
}
|
||||
|
||||
Directory.Delete(outsideDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFilesAsync_RootWithSymlinkedFile_DoesNotLeakContentAsync()
|
||||
{
|
||||
// Arrange — symlinked file at root level; search should not return its content.
|
||||
string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_search_root_" + Guid.NewGuid().ToString("N") + ".txt");
|
||||
File.WriteAllText(outsideFile, "ROOT_LEVEL_SECRET_CONTENT");
|
||||
|
||||
string linkPath = Path.Combine(this._rootDir, "env-link.txt");
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryCreateFileSymbolicLink(linkPath, outsideFile))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Also add a normal file to confirm search still works for non-symlinks.
|
||||
await this._store.WriteFileAsync("normal.txt", "NORMAL_CONTENT");
|
||||
|
||||
// Act — search at root should skip the symlinked file.
|
||||
var results = await this._store.SearchFilesAsync("", "SECRET_CONTENT");
|
||||
|
||||
// Assert — no results from the symlinked file.
|
||||
Assert.Empty(results);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(linkPath))
|
||||
{
|
||||
File.Delete(linkPath);
|
||||
}
|
||||
|
||||
File.Delete(outsideFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFilesAsync_RootWithSymlinkedFile_ExcludesSymlinkAsync()
|
||||
{
|
||||
// Arrange — symlinked file at root level; listing should not include it.
|
||||
string outsideFile = Path.Combine(Path.GetTempPath(), "symlink_list_root_" + Guid.NewGuid().ToString("N") + ".txt");
|
||||
File.WriteAllText(outsideFile, "OUTSIDE");
|
||||
|
||||
string linkPath = Path.Combine(this._rootDir, "hidden-link.txt");
|
||||
|
||||
try
|
||||
{
|
||||
if (!TryCreateFileSymbolicLink(linkPath, outsideFile))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Also add a normal file.
|
||||
await this._store.WriteFileAsync("visible.txt", "VISIBLE");
|
||||
|
||||
// Act
|
||||
var files = await this._store.ListFilesAsync("");
|
||||
|
||||
// Assert — symlinked file should not appear in listing.
|
||||
Assert.DoesNotContain("hidden-link.txt", files);
|
||||
Assert.Contains("visible.txt", files);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(linkPath))
|
||||
{
|
||||
File.Delete(linkPath);
|
||||
}
|
||||
|
||||
File.Delete(outsideFile);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Execution;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public sealed class RouteBuilderTests
|
||||
{
|
||||
public enum HandlerOverload
|
||||
{
|
||||
SyncWithCancellation = 0,
|
||||
SyncWithoutCancellation = 1,
|
||||
AsyncWithCancellation = 2,
|
||||
AsyncWithoutCancellation = 3,
|
||||
}
|
||||
|
||||
private sealed record TestPayload(string Value);
|
||||
|
||||
private sealed class HandlerInvocation
|
||||
{
|
||||
public object? Message { get; private set; }
|
||||
|
||||
public IWorkflowContext? Context { get; private set; }
|
||||
|
||||
public CancellationToken CancellationToken { get; private set; }
|
||||
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
public void Capture(object? message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.Message = message;
|
||||
this.Context = context;
|
||||
this.CancellationToken = cancellationToken;
|
||||
this.InvocationCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestExternalRequestContext : IExternalRequestContext, IExternalRequestSink
|
||||
{
|
||||
public List<RequestPort> RegisteredPorts { get; } = [];
|
||||
|
||||
public List<ExternalRequest> PostedRequests { get; } = [];
|
||||
|
||||
public IExternalRequestSink RegisterPort(RequestPort port)
|
||||
{
|
||||
this.RegisteredPorts.Add(port);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ValueTask PostAsync(ExternalRequest request)
|
||||
{
|
||||
this.PostedRequests.Add(request);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HandlerOverload.SyncWithCancellation)]
|
||||
[InlineData(HandlerOverload.SyncWithoutCancellation)]
|
||||
[InlineData(HandlerOverload.AsyncWithCancellation)]
|
||||
[InlineData(HandlerOverload.AsyncWithoutCancellation)]
|
||||
public async Task AddHandler_VoidOverloads_RouteExpectedMessageAsync(HandlerOverload overload)
|
||||
{
|
||||
// Arrange
|
||||
RouteBuilder routeBuilder = new(null);
|
||||
HandlerInvocation invocation = new();
|
||||
CancellationToken cancellationToken = new CancellationTokenSource().Token;
|
||||
RegisterVoidHandler(routeBuilder, invocation, overload);
|
||||
MessageRouter router = routeBuilder.Build();
|
||||
TestWorkflowContext context = new("executor");
|
||||
|
||||
// Act
|
||||
CallResult? result = await router.RouteMessageAsync("hello", context, cancellationToken: cancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.IsSuccess.Should().BeTrue();
|
||||
result.IsVoid.Should().BeTrue();
|
||||
result.Result.Should().BeNull();
|
||||
invocation.InvocationCount.Should().Be(1);
|
||||
invocation.Message.Should().Be("hello");
|
||||
invocation.Context.Should().BeSameAs(context);
|
||||
|
||||
if (UsesCancellationToken(overload))
|
||||
{
|
||||
invocation.CancellationToken.Should().Be(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HandlerOverload.SyncWithCancellation)]
|
||||
[InlineData(HandlerOverload.SyncWithoutCancellation)]
|
||||
[InlineData(HandlerOverload.AsyncWithCancellation)]
|
||||
[InlineData(HandlerOverload.AsyncWithoutCancellation)]
|
||||
public async Task AddHandler_ResultOverloads_RouteExpectedMessageAsync(HandlerOverload overload)
|
||||
{
|
||||
// Arrange
|
||||
RouteBuilder routeBuilder = new(null);
|
||||
HandlerInvocation invocation = new();
|
||||
CancellationToken cancellationToken = new CancellationTokenSource().Token;
|
||||
RegisterResultHandler(routeBuilder, invocation, overload);
|
||||
MessageRouter router = routeBuilder.Build();
|
||||
TestWorkflowContext context = new("executor");
|
||||
|
||||
// Act
|
||||
CallResult? result = await router.RouteMessageAsync("hello", context, cancellationToken: cancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.IsSuccess.Should().BeTrue();
|
||||
result.IsVoid.Should().BeFalse();
|
||||
result.Result.Should().Be("HELLO");
|
||||
router.DefaultOutputTypes.Should().Contain(typeof(string));
|
||||
invocation.InvocationCount.Should().Be(1);
|
||||
invocation.Message.Should().Be("hello");
|
||||
invocation.Context.Should().BeSameAs(context);
|
||||
|
||||
if (UsesCancellationToken(overload))
|
||||
{
|
||||
invocation.CancellationToken.Should().Be(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HandlerOverload.SyncWithCancellation)]
|
||||
[InlineData(HandlerOverload.SyncWithoutCancellation)]
|
||||
[InlineData(HandlerOverload.AsyncWithCancellation)]
|
||||
[InlineData(HandlerOverload.AsyncWithoutCancellation)]
|
||||
public async Task AddCatchAll_VoidOverloads_RouteUnexpectedMessageAsync(HandlerOverload overload)
|
||||
{
|
||||
// Arrange
|
||||
RouteBuilder routeBuilder = new(null);
|
||||
HandlerInvocation invocation = new();
|
||||
CancellationToken cancellationToken = new CancellationTokenSource().Token;
|
||||
TestPayload payload = new("hello");
|
||||
RegisterVoidCatchAll(routeBuilder, invocation, overload);
|
||||
MessageRouter router = routeBuilder.Build();
|
||||
TestWorkflowContext context = new("executor");
|
||||
|
||||
// Act
|
||||
CallResult? result = await router.RouteMessageAsync(payload, context, cancellationToken: cancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.IsSuccess.Should().BeTrue();
|
||||
result.IsVoid.Should().BeTrue();
|
||||
result.Result.Should().BeNull();
|
||||
invocation.InvocationCount.Should().Be(1);
|
||||
invocation.Message.Should().BeEquivalentTo(new PortableValue(payload));
|
||||
invocation.Context.Should().BeSameAs(context);
|
||||
|
||||
if (UsesCancellationToken(overload))
|
||||
{
|
||||
invocation.CancellationToken.Should().Be(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HandlerOverload.SyncWithCancellation)]
|
||||
[InlineData(HandlerOverload.SyncWithoutCancellation)]
|
||||
[InlineData(HandlerOverload.AsyncWithCancellation)]
|
||||
[InlineData(HandlerOverload.AsyncWithoutCancellation)]
|
||||
public async Task AddCatchAll_ResultOverloads_RouteUnexpectedMessageAsync(HandlerOverload overload)
|
||||
{
|
||||
// Arrange
|
||||
RouteBuilder routeBuilder = new(null);
|
||||
HandlerInvocation invocation = new();
|
||||
CancellationToken cancellationToken = new CancellationTokenSource().Token;
|
||||
TestPayload payload = new("hello");
|
||||
RegisterResultCatchAll(routeBuilder, invocation, overload);
|
||||
MessageRouter router = routeBuilder.Build();
|
||||
TestWorkflowContext context = new("executor");
|
||||
|
||||
// Act
|
||||
CallResult? result = await router.RouteMessageAsync(payload, context, cancellationToken: cancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.IsSuccess.Should().BeTrue();
|
||||
result.IsVoid.Should().BeFalse();
|
||||
result.Result.Should().Be("HELLO");
|
||||
invocation.InvocationCount.Should().Be(1);
|
||||
invocation.Message.Should().BeEquivalentTo(new PortableValue(payload));
|
||||
invocation.Context.Should().BeSameAs(context);
|
||||
|
||||
if (UsesCancellationToken(overload))
|
||||
{
|
||||
invocation.CancellationToken.Should().Be(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddHandlerUntyped_VoidAndResultOverloads_RouteExpectedMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
RouteBuilder routeBuilder = new(null);
|
||||
HandlerInvocation voidInvocation = new();
|
||||
HandlerInvocation resultInvocation = new();
|
||||
CancellationToken cancellationToken = new CancellationTokenSource().Token;
|
||||
routeBuilder.AddHandlerUntyped(typeof(string), (message, context, token) =>
|
||||
{
|
||||
voidInvocation.Capture(message, context, token);
|
||||
return default;
|
||||
});
|
||||
routeBuilder.AddHandlerUntyped<int>(typeof(int), (message, context, token) =>
|
||||
{
|
||||
resultInvocation.Capture(message, context, token);
|
||||
return new((int)message + 1);
|
||||
});
|
||||
MessageRouter router = routeBuilder.Build();
|
||||
TestWorkflowContext context = new("executor");
|
||||
|
||||
// Act
|
||||
CallResult? voidResult = await router.RouteMessageAsync("hello", context, cancellationToken: cancellationToken);
|
||||
CallResult? typedResult = await router.RouteMessageAsync(41, context, cancellationToken: cancellationToken);
|
||||
|
||||
// Assert
|
||||
voidResult.Should().NotBeNull();
|
||||
voidResult!.IsVoid.Should().BeTrue();
|
||||
voidInvocation.Message.Should().Be("hello");
|
||||
voidInvocation.Context.Should().BeSameAs(context);
|
||||
voidInvocation.CancellationToken.Should().Be(cancellationToken);
|
||||
|
||||
typedResult.Should().NotBeNull();
|
||||
typedResult!.Result.Should().Be(42);
|
||||
router.DefaultOutputTypes.Should().Contain(typeof(int));
|
||||
resultInvocation.Message.Should().Be(41);
|
||||
resultInvocation.Context.Should().BeSameAs(context);
|
||||
resultInvocation.CancellationToken.Should().Be(cancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddHandler_ForPortableValue_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
RouteBuilder routeBuilder = new(null);
|
||||
|
||||
// Act
|
||||
Action act = () => routeBuilder.AddHandler<PortableValue>((message, context) => { });
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*Use AddCatchAll()*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddHandler_DuplicateRegistrationWithoutOverwrite_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
RouteBuilder routeBuilder = new(null);
|
||||
routeBuilder.AddHandler<string>((message, context) => { });
|
||||
|
||||
// Act
|
||||
Action act = () => routeBuilder.AddHandler<string>((message, context) => { });
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*already registered*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddHandler_OverwriteWithoutExistingRegistration_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
RouteBuilder routeBuilder = new(null);
|
||||
|
||||
// Act
|
||||
Action act = () => routeBuilder.AddHandler<string>((message, context) => { }, overwrite: true);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*has not yet been registered*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddHandler_OverwriteExistingRegistration_RoutesUpdatedHandlerAsync()
|
||||
{
|
||||
// Arrange
|
||||
RouteBuilder routeBuilder = new(null);
|
||||
routeBuilder.AddHandler<string>((message, context) => context.SendMessageAsync("first"));
|
||||
routeBuilder.AddHandler<string>((message, context) => context.SendMessageAsync("second"), overwrite: true);
|
||||
MessageRouter router = routeBuilder.Build();
|
||||
TestWorkflowContext context = new("executor");
|
||||
|
||||
// Act
|
||||
_ = await router.RouteMessageAsync("hello", context);
|
||||
|
||||
// Assert
|
||||
context.SentMessages.Should().ContainSingle().Which.Should().Be("second");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddCatchAll_DuplicateRegistrationWithoutOverwrite_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
RouteBuilder routeBuilder = new(null);
|
||||
routeBuilder.AddCatchAll((message, context) => { });
|
||||
|
||||
// Act
|
||||
Action act = () => routeBuilder.AddCatchAll((message, context) => { });
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*already registered*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddCatchAll_OverwriteExistingRegistration_RoutesUpdatedHandlerAsync()
|
||||
{
|
||||
// Arrange
|
||||
RouteBuilder routeBuilder = new(null);
|
||||
routeBuilder.AddCatchAll((message, context) => context.SendMessageAsync("first"));
|
||||
routeBuilder.AddCatchAll((message, context) => context.SendMessageAsync("second"), overwrite: true);
|
||||
MessageRouter router = routeBuilder.Build();
|
||||
TestWorkflowContext context = new("executor");
|
||||
|
||||
// Act
|
||||
_ = await router.RouteMessageAsync(new TestPayload("hello"), context);
|
||||
|
||||
// Assert
|
||||
context.SentMessages.Should().ContainSingle().Which.Should().Be("second");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddPortHandler_WithoutExternalRequestContext_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
RouteBuilder routeBuilder = new(null);
|
||||
|
||||
// Act
|
||||
Action act = () => routeBuilder.AddPortHandler<string, int>("port", (response, context, cancellationToken) => default, out _);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*external request context is required*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddPortHandler_RoutesMatchingExternalResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestExternalRequestContext externalRequestContext = new();
|
||||
RouteBuilder routeBuilder = new(externalRequestContext);
|
||||
HandlerInvocation invocation = new();
|
||||
routeBuilder.AddPortHandler<string, int>("port", (response, context, cancellationToken) =>
|
||||
{
|
||||
invocation.Capture(response, context, cancellationToken);
|
||||
return default;
|
||||
}, out PortBinding portBinding);
|
||||
await portBinding.PostRequestAsync("request", requestId: "req-1");
|
||||
MessageRouter router = routeBuilder.Build();
|
||||
TestWorkflowContext context = new("executor");
|
||||
CancellationToken cancellationToken = new CancellationTokenSource().Token;
|
||||
ExternalResponse response = externalRequestContext.PostedRequests.Single().CreateResponse(42);
|
||||
|
||||
// Act
|
||||
CallResult? result = await router.RouteMessageAsync(response, context, cancellationToken: cancellationToken);
|
||||
|
||||
// Assert
|
||||
externalRequestContext.RegisteredPorts.Should().ContainSingle(port => port.Id == "port");
|
||||
externalRequestContext.PostedRequests.Should().ContainSingle(request => request.RequestId == "req-1");
|
||||
result.Should().NotBeNull();
|
||||
result!.IsSuccess.Should().BeTrue();
|
||||
result.Result.Should().BeSameAs(response);
|
||||
invocation.InvocationCount.Should().Be(1);
|
||||
invocation.Message.Should().Be(42);
|
||||
invocation.Context.Should().BeSameAs(context);
|
||||
invocation.CancellationToken.Should().Be(cancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddPortHandler_UnknownPort_ReturnsExceptionResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestExternalRequestContext externalRequestContext = new();
|
||||
RouteBuilder routeBuilder = new(externalRequestContext);
|
||||
routeBuilder.AddPortHandler<string, int>("port", (response, context, cancellationToken) => default, out _);
|
||||
MessageRouter router = routeBuilder.Build();
|
||||
ExternalRequest request = ExternalRequest.Create(RequestPort.Create<string, int>("other"), "request", requestId: "req-1");
|
||||
|
||||
// Act
|
||||
CallResult? result = await router.RouteMessageAsync(request.CreateResponse(42), new TestWorkflowContext("executor"));
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.IsSuccess.Should().BeFalse();
|
||||
result.Exception.Should().BeOfType<InvalidOperationException>();
|
||||
result.Exception!.Message.Should().Contain("Unknown port");
|
||||
}
|
||||
|
||||
private static void RegisterVoidHandler(RouteBuilder routeBuilder, HandlerInvocation invocation, HandlerOverload overload)
|
||||
{
|
||||
switch (overload)
|
||||
{
|
||||
case HandlerOverload.SyncWithCancellation:
|
||||
routeBuilder.AddHandler<string>((message, context, cancellationToken) => invocation.Capture(message, context, cancellationToken));
|
||||
break;
|
||||
case HandlerOverload.SyncWithoutCancellation:
|
||||
routeBuilder.AddHandler<string>((message, context) => invocation.Capture(message, context));
|
||||
break;
|
||||
case HandlerOverload.AsyncWithCancellation:
|
||||
routeBuilder.AddHandler<string>((message, context, cancellationToken) =>
|
||||
{
|
||||
invocation.Capture(message, context, cancellationToken);
|
||||
return default;
|
||||
});
|
||||
break;
|
||||
case HandlerOverload.AsyncWithoutCancellation:
|
||||
routeBuilder.AddHandler<string>((message, context) =>
|
||||
{
|
||||
invocation.Capture(message, context);
|
||||
return default;
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(overload));
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterResultHandler(RouteBuilder routeBuilder, HandlerInvocation invocation, HandlerOverload overload)
|
||||
{
|
||||
switch (overload)
|
||||
{
|
||||
case HandlerOverload.SyncWithCancellation:
|
||||
routeBuilder.AddHandler<string, string>((message, context, cancellationToken) =>
|
||||
{
|
||||
invocation.Capture(message, context, cancellationToken);
|
||||
return NormalizeHandlerResult(message);
|
||||
});
|
||||
break;
|
||||
case HandlerOverload.SyncWithoutCancellation:
|
||||
routeBuilder.AddHandler<string, string>((message, context) =>
|
||||
{
|
||||
invocation.Capture(message, context);
|
||||
return NormalizeHandlerResult(message);
|
||||
});
|
||||
break;
|
||||
case HandlerOverload.AsyncWithCancellation:
|
||||
Func<string, IWorkflowContext, CancellationToken, ValueTask<string>> asyncHandlerWithCancellation = (message, context, cancellationToken) =>
|
||||
{
|
||||
invocation.Capture(message, context, cancellationToken);
|
||||
return new ValueTask<string>(NormalizeHandlerResult(message));
|
||||
};
|
||||
routeBuilder.AddHandler(asyncHandlerWithCancellation);
|
||||
break;
|
||||
case HandlerOverload.AsyncWithoutCancellation:
|
||||
Func<string, IWorkflowContext, ValueTask<string>> asyncHandler = (message, context) =>
|
||||
{
|
||||
invocation.Capture(message, context);
|
||||
return new ValueTask<string>(NormalizeHandlerResult(message));
|
||||
};
|
||||
routeBuilder.AddHandler(asyncHandler);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(overload));
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterVoidCatchAll(RouteBuilder routeBuilder, HandlerInvocation invocation, HandlerOverload overload)
|
||||
{
|
||||
switch (overload)
|
||||
{
|
||||
case HandlerOverload.SyncWithCancellation:
|
||||
routeBuilder.AddCatchAll((message, context, cancellationToken) => invocation.Capture(message, context, cancellationToken));
|
||||
break;
|
||||
case HandlerOverload.SyncWithoutCancellation:
|
||||
routeBuilder.AddCatchAll((message, context) => invocation.Capture(message, context));
|
||||
break;
|
||||
case HandlerOverload.AsyncWithCancellation:
|
||||
routeBuilder.AddCatchAll((message, context, cancellationToken) =>
|
||||
{
|
||||
invocation.Capture(message, context, cancellationToken);
|
||||
return default;
|
||||
});
|
||||
break;
|
||||
case HandlerOverload.AsyncWithoutCancellation:
|
||||
routeBuilder.AddCatchAll((message, context) =>
|
||||
{
|
||||
invocation.Capture(message, context);
|
||||
return default;
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(overload));
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterResultCatchAll(RouteBuilder routeBuilder, HandlerInvocation invocation, HandlerOverload overload)
|
||||
{
|
||||
switch (overload)
|
||||
{
|
||||
case HandlerOverload.SyncWithCancellation:
|
||||
routeBuilder.AddCatchAll((message, context, cancellationToken) =>
|
||||
{
|
||||
invocation.Capture(message, context, cancellationToken);
|
||||
return NormalizeCatchAllResult(message);
|
||||
});
|
||||
break;
|
||||
case HandlerOverload.SyncWithoutCancellation:
|
||||
routeBuilder.AddCatchAll((message, context) =>
|
||||
{
|
||||
invocation.Capture(message, context);
|
||||
return NormalizeCatchAllResult(message);
|
||||
});
|
||||
break;
|
||||
case HandlerOverload.AsyncWithCancellation:
|
||||
Func<PortableValue, IWorkflowContext, CancellationToken, ValueTask<string>> asyncCatchAllWithCancellation = (message, context, cancellationToken) =>
|
||||
{
|
||||
invocation.Capture(message, context, cancellationToken);
|
||||
return new ValueTask<string>(NormalizeCatchAllResult(message));
|
||||
};
|
||||
routeBuilder.AddCatchAll(asyncCatchAllWithCancellation);
|
||||
break;
|
||||
case HandlerOverload.AsyncWithoutCancellation:
|
||||
Func<PortableValue, IWorkflowContext, ValueTask<string>> asyncCatchAll = (message, context) =>
|
||||
{
|
||||
invocation.Capture(message, context);
|
||||
return new ValueTask<string>(NormalizeCatchAllResult(message));
|
||||
};
|
||||
routeBuilder.AddCatchAll(asyncCatchAll);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(overload));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool UsesCancellationToken(HandlerOverload overload) =>
|
||||
overload is HandlerOverload.SyncWithCancellation or HandlerOverload.AsyncWithCancellation;
|
||||
|
||||
private static string NormalizeHandlerResult(string message) => message.ToUpperInvariant();
|
||||
|
||||
private static string NormalizeCatchAllResult(PortableValue message) => GetPayloadValue(message).ToUpperInvariant();
|
||||
|
||||
private static string GetPayloadValue(PortableValue message)
|
||||
{
|
||||
return message.As<TestPayload>() is TestPayload payload
|
||||
? payload.Value
|
||||
: throw new InvalidOperationException("Expected catch-all message payload to deserialize as TestPayload.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user