diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Core/HttpPluginTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Core/HttpPluginTests.cs index f9a63f8426..1acb0d1dc5 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Core/HttpPluginTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Core/HttpPluginTests.cs @@ -137,47 +137,16 @@ public sealed class HttpPluginTests : IDisposable public async Task ItDoesNotFollowRedirectsAsync() { // Arrange - start a local server that always returns a 302 redirect - using var listener = new System.Net.HttpListener(); - var port = new Random().Next(49152, 65535); - listener.Prefixes.Add($"http://localhost:{port}/"); - listener.Start(); - bool redirectTargetContacted = false; - - _ = Task.Run(async () => - { - while (listener.IsListening) - { - try - { - var ctx = await listener.GetContextAsync(); - if (ctx.Request.Url!.AbsolutePath == "/start") - { - ctx.Response.StatusCode = 302; - ctx.Response.RedirectLocation = $"http://localhost:{port}/secret"; - ctx.Response.Close(); - } - else if (ctx.Request.Url.AbsolutePath == "/secret") - { - redirectTargetContacted = true; - ctx.Response.StatusCode = 200; - ctx.Response.Close(); - } - } - catch (ObjectDisposedException) { break; } - catch (System.Net.HttpListenerException) { break; } - } - }); + await using var server = new RedirectLoopbackServer("secret", "text/plain", []); var plugin = new HttpPlugin() { - AllowedDomains = ["localhost"] + AllowedDomains = [server.BaseUri.Host] }; // Act & Assert - the plugin should throw because 302 is a non-success status - await Assert.ThrowsAsync(() => plugin.GetAsync($"http://localhost:{port}/start")); - Assert.False(redirectTargetContacted, "The redirect target should not have been contacted."); - - listener.Stop(); + await Assert.ThrowsAsync(() => plugin.GetAsync(new Uri(server.BaseUri, "start").AbsoluteUri)); + Assert.False(server.RedirectTargetContacted, "The redirect target should not have been contacted."); } private Mock CreateMock() diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Support/RedirectLoopbackServer.cs b/dotnet/src/Plugins/Plugins.UnitTests/Support/RedirectLoopbackServer.cs new file mode 100644 index 0000000000..162dff67eb --- /dev/null +++ b/dotnet/src/Plugins/Plugins.UnitTests/Support/RedirectLoopbackServer.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace SemanticKernel.Plugins.UnitTests; + +internal sealed class RedirectLoopbackServer : IAsyncDisposable +{ + private readonly TcpListener _listener; + private readonly CancellationTokenSource _cancellationTokenSource = new(); + private readonly Task _serverTask; + private readonly string _redirectTargetPath; + private readonly string _targetContentType; + private readonly byte[] _targetContent; + private int _redirectTargetContacted; + + public RedirectLoopbackServer(string redirectTargetPath, string targetContentType, byte[] targetContent) + { + this._redirectTargetPath = redirectTargetPath.StartsWith("/", StringComparison.Ordinal) ? redirectTargetPath : $"/{redirectTargetPath}"; + this._targetContentType = targetContentType; + this._targetContent = targetContent; + + this._listener = new TcpListener(IPAddress.Loopback, 0); + this._listener.Start(); + var endpoint = (IPEndPoint)this._listener.LocalEndpoint; + this.BaseUri = new Uri($"http://{endpoint.Address}:{endpoint.Port}/"); + this._serverTask = this.RunAsync(); + } + + public Uri BaseUri { get; } + + public bool RedirectTargetContacted => Volatile.Read(ref this._redirectTargetContacted) != 0; + + public async ValueTask DisposeAsync() + { + this._cancellationTokenSource.Cancel(); + this._listener.Stop(); + await this._serverTask.ConfigureAwait(false); + this._cancellationTokenSource.Dispose(); + } + + private async Task RunAsync() + { + while (!this._cancellationTokenSource.IsCancellationRequested) + { + TcpClient client; + try + { + client = await this._listener.AcceptTcpClientAsync(this._cancellationTokenSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + catch (ObjectDisposedException) when (this._cancellationTokenSource.IsCancellationRequested) + { + break; + } + catch (SocketException) when (this._cancellationTokenSource.IsCancellationRequested) + { + break; + } + + using (client) + { + try + { + await this.HandleRequestAsync(client, this._cancellationTokenSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (this._cancellationTokenSource.IsCancellationRequested) + { + break; + } + catch (ObjectDisposedException) when (this._cancellationTokenSource.IsCancellationRequested) + { + break; + } + catch (IOException) + { + // Ignore client disconnects / truncated requests to keep the test helper stable. + } + catch (SocketException) when (this._cancellationTokenSource.IsCancellationRequested) + { + break; + } + } + } + } + + private async Task HandleRequestAsync(TcpClient client, CancellationToken cancellationToken) + { + var stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true); + var requestLine = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + string? header; + do + { + header = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + } + while (!string.IsNullOrEmpty(header)); + + var requestTarget = requestLine?.Split(' ')[1]; + if (requestTarget == "/start") + { + var response = $"HTTP/1.1 302 Found\r\nLocation: {new Uri(this.BaseUri, this._redirectTargetPath.TrimStart('/'))}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + await stream.WriteAsync(Encoding.ASCII.GetBytes(response), cancellationToken).ConfigureAwait(false); + return; + } + + Interlocked.Exchange(ref this._redirectTargetContacted, 1); + var responseHeaders = $"HTTP/1.1 200 OK\r\nContent-Type: {this._targetContentType}\r\nContent-Length: {this._targetContent.Length}\r\nConnection: close\r\n\r\n"; + await stream.WriteAsync(Encoding.ASCII.GetBytes(responseHeaders), cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(this._targetContent, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/WebFileDownloadPluginTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/WebFileDownloadPluginTests.cs index dbba79540f..a90989df48 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/WebFileDownloadPluginTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/WebFileDownloadPluginTests.cs @@ -82,38 +82,7 @@ public sealed class WebFileDownloadPluginTests : IDisposable public async Task DownloadToFileDoesNotFollowRedirectsAsync() { // Arrange - start a local server that returns a 302 redirect - using var listener = new System.Net.HttpListener(); - var port = new Random().Next(49152, 65535); - listener.Prefixes.Add($"http://localhost:{port}/"); - listener.Start(); - bool redirectTargetContacted = false; - - _ = Task.Run(async () => - { - while (listener.IsListening) - { - try - { - var ctx = await listener.GetContextAsync(); - if (ctx.Request.Url!.AbsolutePath == "/start") - { - ctx.Response.StatusCode = 302; - ctx.Response.RedirectLocation = $"http://localhost:{port}/secret.png"; - ctx.Response.Close(); - } - else if (ctx.Request.Url.AbsolutePath == "/secret.png") - { - redirectTargetContacted = true; - ctx.Response.StatusCode = 200; - ctx.Response.ContentType = "image/png"; - ctx.Response.OutputStream.Write(new byte[] { 0x89, 0x50, 0x4E, 0x47 }); - ctx.Response.Close(); - } - } - catch (ObjectDisposedException) { break; } - catch (System.Net.HttpListenerException) { break; } - } - }); + await using var server = new RedirectLoopbackServer("secret.png", "image/png", [0x89, 0x50, 0x4E, 0x47]); var folderPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); var filePath = Path.Combine(folderPath, "file.png"); @@ -121,20 +90,19 @@ public sealed class WebFileDownloadPluginTests : IDisposable var webFileDownload = new WebFileDownloadPlugin() { - AllowedDomains = ["localhost"], + AllowedDomains = [server.BaseUri.Host], AllowedFolders = [folderPath] }; try { // Act & Assert - the plugin should throw because 302 is a non-success status - await Assert.ThrowsAsync(() => webFileDownload.DownloadToFileAsync(new Uri($"http://localhost:{port}/start"), filePath)); - Assert.False(redirectTargetContacted, "The redirect target should not have been contacted."); + await Assert.ThrowsAsync(() => webFileDownload.DownloadToFileAsync(new Uri(server.BaseUri, "start"), filePath)); + Assert.False(server.RedirectTargetContacted, "The redirect target should not have been contacted."); Assert.False(Path.Exists(filePath)); } finally { - listener.Stop(); if (Path.Exists(folderPath)) { Directory.Delete(folderPath, true);