diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 4009f16938a8..ef20ea712b10 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -441,9 +441,9 @@ - + https://github.com/dotnet/source-build-externals - e4ddbedd151b969514f2c5b756616707c31e0bfb + 4e60131607fd144eb86fe4487f1a37da940ca990 diff --git a/sdk.sln b/sdk.sln index c57d23959f8c..1e3a6f52e474 100644 --- a/sdk.sln +++ b/sdk.sln @@ -507,6 +507,8 @@ Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "Microsoft.WebTools.AspireSe EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Net.Sdk.Compilers.Toolset", "src\Microsoft.Net.Sdk.Compilers.Toolset\Microsoft.Net.Sdk.Compilers.Toolset.csproj", "{FA579C03-2EB4-4D47-88EE-BFF339E96FAF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.WebTools.AspireService.Tests", "test\Microsoft.WebTools.AspireService.Tests\Microsoft.WebTools.AspireService.Tests.csproj", "{1F0B4B3C-DC88-4740-B04F-1707102E9930}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -965,6 +967,10 @@ Global {FA579C03-2EB4-4D47-88EE-BFF339E96FAF}.Debug|Any CPU.Build.0 = Debug|Any CPU {FA579C03-2EB4-4D47-88EE-BFF339E96FAF}.Release|Any CPU.ActiveCfg = Release|Any CPU {FA579C03-2EB4-4D47-88EE-BFF339E96FAF}.Release|Any CPU.Build.0 = Release|Any CPU + {1F0B4B3C-DC88-4740-B04F-1707102E9930}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F0B4B3C-DC88-4740-B04F-1707102E9930}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F0B4B3C-DC88-4740-B04F-1707102E9930}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F0B4B3C-DC88-4740-B04F-1707102E9930}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1143,6 +1149,7 @@ Global {19014C60-F87C-4CC7-AC0F-C41B6126EBCE} = {71A9F549-0EB6-41F9-BC16-4A6C5007FC91} {94C8526E-DCC2-442F-9868-3DD0BA2688BE} = {71A9F549-0EB6-41F9-BC16-4A6C5007FC91} {FA579C03-2EB4-4D47-88EE-BFF339E96FAF} = {22AB674F-ED91-4FBC-BFEE-8A1E82F9F05E} + {1F0B4B3C-DC88-4740-B04F-1707102E9930} = {580D1AE7-AA8F-4912-8B76-105594E00B3B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FB8F26CE-4DE6-433F-B32A-79183020BBD6} @@ -1150,6 +1157,7 @@ Global GlobalSection(SharedMSBuildProjectFiles) = preSolution src\Compatibility\ApiCompat\Microsoft.DotNet.ApiCompat.Shared\Microsoft.DotNet.ApiCompat.Shared.projitems*{03c5a84a-982b-4f38-ac73-ab832c645c4a}*SharedItemsImports = 5 src\Compatibility\ApiCompat\Microsoft.DotNet.ApiCompat.Shared\Microsoft.DotNet.ApiCompat.Shared.projitems*{0a3c9afd-f6e6-4a5d-83fb-93bf66732696}*SharedItemsImports = 5 + src\BuiltInTools\AspireService\Microsoft.WebTools.AspireService.projitems*{1f0b4b3c-dc88-4740-b04f-1707102e9930}*SharedItemsImports = 5 src\BuiltInTools\AspireService\Microsoft.WebTools.AspireService.projitems*{94c8526e-dcc2-442f-9868-3dd0ba2688be}*SharedItemsImports = 13 src\Compatibility\ApiCompat\Microsoft.DotNet.ApiCompat.Shared\Microsoft.DotNet.ApiCompat.Shared.projitems*{9d36039f-d0a1-462f-85b4-81763c6b02cb}*SharedItemsImports = 13 src\Compatibility\ApiCompat\Microsoft.DotNet.ApiCompat.Shared\Microsoft.DotNet.ApiCompat.Shared.projitems*{a9103b98-d888-4260-8a05-fa36f640698a}*SharedItemsImports = 5 diff --git a/src/BuiltInTools/AspireService/AspireServerService.cs b/src/BuiltInTools/AspireService/AspireServerService.cs index d777ce951154..3b620131f73f 100644 --- a/src/BuiltInTools/AspireService/AspireServerService.cs +++ b/src/BuiltInTools/AspireService/AspireServerService.cs @@ -1,18 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Diagnostics; using System.Net; using System.Net.WebSockets; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -using System.Text; using System.Text.Json; using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; diff --git a/src/BuiltInTools/AspireService/Helpers/CertGenerator.cs b/src/BuiltInTools/AspireService/Helpers/CertGenerator.cs index 189a88c48ed5..a0b0f7766d48 100644 --- a/src/BuiltInTools/AspireService/Helpers/CertGenerator.cs +++ b/src/BuiltInTools/AspireService/Helpers/CertGenerator.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -35,7 +34,11 @@ public static X509Certificate2 GenerateCert() // The file will be automatically generated by the following call and disposed when the returned cert is disposed. using (cert) { +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), password: null, X509KeyStorageFlags.UserKeySet); +#else return new X509Certificate2(cert.Export(X509ContentType.Pfx), "", X509KeyStorageFlags.UserKeySet); +#endif } } else diff --git a/test/Microsoft.WebTools.AspireService.Tests/AspireServerServiceTests.cs b/test/Microsoft.WebTools.AspireService.Tests/AspireServerServiceTests.cs new file mode 100644 index 000000000000..69d06b8c0479 --- /dev/null +++ b/test/Microsoft.WebTools.AspireService.Tests/AspireServerServiceTests.cs @@ -0,0 +1,532 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.Net; +using System.Net.Http.Headers; +using System.Net.WebSockets; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using Microsoft.WebTools.AspireServer.Helpers; +using Microsoft.WebTools.AspireServer.Models; + +namespace Microsoft.WebTools.AspireServer.UnitTests; + +public class AspireServerServiceTests +{ + private const string Project1Path = @"c:\test\Projects\project1.csproj"; + private const int ProcessId = 34213; + private const string DcpId = "myid"; + private const string SpecificProfileName = "SpecificProfile"; + private const string VersionedSessionUrl = $"{RunSessionRequest.Url}?{RunSessionRequest.VersionQuery}={RunSessionRequest.OurProtocolVersion}"; + + private static readonly TestRunSessionRequest Project1SessionRequest = new TestRunSessionRequest(Project1Path, debugging: false, launchProfile: null, disableLaunchProfile: false) + { + args = new List { "--project1Arg" }, + env = new List { new EnvVar { Name = "var1", Value = "value1" } } + }; + + [Fact] + public async Task SessionStarted_Test() + { + var mocks = new Mocks(); + + var server = await GetAspireServer(mocks); + + // Start listening + TaskCompletionSource connected = new(); + + TaskCompletionSource notificationTask = new(); + _ = listenForSessionUpdatesAsync(server, connected, (sn) => + { + notificationTask.SetResult((SessionChangeNotification)sn); + }); + + await connected.Task; + + await server.SessionStartedAsync(DcpId,"1", ProcessId, CancellationToken.None); + + var result = await notificationTask.Task; + + Assert.Equal(ProcessId, result.PID); + Assert.Equal("1", result.SessionId); + + await server.DisposeAsync(); + + mocks.Verify(); + } + + [Fact] + public async Task SessionEndedAsync_Test() + { + var mocks = new Mocks(); + + var server = await GetAspireServer(mocks); + + // Start listening + TaskCompletionSource connected = new(); + TaskCompletionSource sessionEndNotificationTask = new(); + _ = listenForSessionUpdatesAsync(server, connected, (sn) => + { + if (sn.NotificationType == NotificationType.SessionTerminated) + { + sessionEndNotificationTask.SetResult((SessionChangeNotification)sn); + } + }); + + await connected.Task; + + await server.SessionEndedAsync(DcpId, "1", ProcessId, 130, CancellationToken.None); + + var result = await sessionEndNotificationTask.Task; + Assert.Equal(ProcessId, result.PID); + Assert.Equal("1", result.SessionId); + Assert.Equal(130, result.ExitCode); + + await server.DisposeAsync(); + + mocks.Verify(); + } + + [Fact] + public async Task LaunchProject_Success() + { + var mocks = new Mocks(); + + mocks.GetOrCreate() + .ImplementStartProjectAsync(DcpId, "2"); + + var server = await GetAspireServer(mocks); + var tokens = await server.GetServerVariablesAsync(); + + using HttpClient client = GetHttpClient(tokens); + + HttpResponseMessage response; + response = await client.PutAsJsonAsync(VersionedSessionUrl, Project1SessionRequest); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal($"{client.BaseAddress}run_session/2", response.Headers.Location.AbsoluteUri); + + await server.DisposeAsync(); + + mocks.Verify(); + } + + [Fact] + public async Task LaunchProject_Success_ThenStopProcessRequest() + { + var mocks = new Mocks(); + + mocks.GetOrCreate() + .ImplementStartProjectAsync(DcpId, "2") + .ImplementStopSessionAsync(DcpId, "2", exists: true) + .ImplementStopSessionAsync(DcpId, "3", exists: false); + + var server = await GetAspireServer(mocks); + var tokens = await server.GetServerVariablesAsync(); + + using HttpClient client = GetHttpClient(tokens); + + var response = await client.PutAsJsonAsync(VersionedSessionUrl, Project1SessionRequest); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + // Now send a stop session + response = await client.DeleteAsync(RunSessionRequest.Url + "/2"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Validate NoContent response if session not found + response = await client.DeleteAsync(RunSessionRequest.Url + "/3"); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + await server.DisposeAsync(); + + mocks.Verify(); + } + + [Fact] + public async Task LaunchProject_FailedToLaunchProject() + { + var mocks = new Mocks(); + + mocks.GetOrCreate() + .ImplementStartProjectAsync(DcpId, "2", new Exception("Launch project failed")); + + var server = await GetAspireServer(mocks); + + var tokens = await server.GetServerVariablesAsync(); + using HttpClient client = GetHttpClient(tokens); + + var response = await client.PutAsJsonAsync(VersionedSessionUrl, Project1SessionRequest); + + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString()); + Assert.Equal("{\"error\":{\"message\":\"Launch project failed\"}}", await response.Content.ReadAsStringAsync()); + + await server.DisposeAsync(); + mocks.Verify(); + } + + [Fact] + public async Task LaunchProject_FailNoBearerToken() + { + var mocks = new Mocks(); + + var server = await GetAspireServer(mocks); + + var tokens = await server.GetServerVariablesAsync(); + using HttpClient client = GetHttpClient(tokens); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "badToken"); + + var response = await client.PutAsJsonAsync(VersionedSessionUrl, Project1SessionRequest); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + + await server.DisposeAsync(); + mocks.Verify(); + } + + [Fact] + public async Task LaunchProject_FailWrongUrl() + { + var mocks = new Mocks(); + + var server = await GetAspireServer(mocks); + + var tokens = await server.GetServerVariablesAsync(); + using HttpClient client = GetHttpClient(tokens); + + var response = await client.PutAsJsonAsync("/run_badurl", Project1SessionRequest); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + await server.DisposeAsync(); + + mocks.Verify(); + } + + [Fact] + public async Task LaunchProject_NotAPUTRequest() + { + var mocks = new Mocks(); + + var aspireServer = await GetAspireServer(mocks); + + var tokens = await aspireServer.GetServerVariablesAsync(); + using HttpClient client = GetHttpClient(tokens); + + var response = await client.PostAsJsonAsync(VersionedSessionUrl, Project1SessionRequest); + + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + + await aspireServer.DisposeAsync(); + + mocks.Verify(); + } + + [Fact] + public async Task StopSession_FailNoBearerToken() + { + var mocks = new Mocks(); + + var server = await GetAspireServer(mocks); + + var tokens = await server.GetServerVariablesAsync(); + using HttpClient client = GetHttpClient(tokens); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "badToken"); + + var response = await client.DeleteAsync(RunSessionRequest.Url + "/2"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + + await server.DisposeAsync(); + mocks.Verify(); + } + + [Fact] + public async Task Info_Success() + { + var mocks = new Mocks(); + + var server = await GetAspireServer(mocks); + + var tokens = await server.GetServerVariablesAsync(); + using HttpClient client = GetHttpClient(tokens); + + var response = await client.GetAsync(InfoResponse.Url); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + await server.DisposeAsync(); + mocks.Verify(); + } + + [Fact] + public async Task Info_FailNoBearerToken() + { + var mocks = new Mocks(); + + var server = await GetAspireServer(mocks); + + var tokens = await server.GetServerVariablesAsync(); + using HttpClient client = GetHttpClient(tokens); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "badToken"); + + var response = await client.GetAsync(InfoResponse.Url); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + + await server.DisposeAsync(); + mocks.Verify(); + } + + [Fact] + public async Task SendLogMessageAsync_Test() + { + var mocks = new Mocks(); + + var aspireServer = await GetAspireServer(mocks); + + + // Start listening + TaskCompletionSource connected = new(); + TaskCompletionSource notificationTask = new(); + _ = listenForSessionUpdatesAsync(aspireServer, connected, (sn) => + { + notificationTask.SetResult((SessionLogsNotification)sn); + }); + + await connected.Task; + + await aspireServer.SendLogMessageAsync(DcpId, "1", isStdErr: false, "My Message", CancellationToken.None); + + var result = await notificationTask.Task; + + Assert.Equal("My Message", result.LogMessage); + Assert.False(result.IsStdErr); + await aspireServer.DisposeAsync(); + + mocks.Verify(); + } + + [Fact] + public async Task GetEnvironmentForOrchestrator_Tests() + { + var mocks = new Mocks(); + + var server = await GetAspireServer(mocks, waitForListening: false); + + // First time should create a key + var envVars = await server.GetServerConnectionEnvironmentAsync(CancellationToken.None); + + Assert.Equal(3, envVars.Count); + var token = envVars[1]; + Assert.NotNull(token.Value); + + // Should return the same + envVars = await server.GetServerConnectionEnvironmentAsync(CancellationToken.None); + Assert.Equal(token, envVars[1]); + + mocks.Verify(); + } + + private async Task listenForSessionUpdatesAsync(AspireServerService aspireServer, TaskCompletionSource connected, Action callback) + { + var tokens = await aspireServer.GetServerVariablesAsync(); + using var httpClient = GetHttpClient(tokens); + + using var ws = new ClientWebSocket(); + ws.Options.SetRequestHeader("Authorization", $"Bearer {tokens.bearerToken}"); + try + { + await ws.ConnectAsync(new Uri($"wss://{tokens.serverAddress}{RunSessionRequest.Url}{SessionNotificationBase.Url}"), httpClient, CancellationToken.None); + } + catch (Exception ex) + { + Assert.Fail("Could not connect to session update endpoint: " + ex.ToString()); + connected.SetResult(false); + return; + } + + connected.SetResult(true); + + while (ws.State == WebSocketState.Open) + { + try + { + var (message, messageType) = await GetSocketMsgAsync(ws); + + if (messageType == WebSocketMessageType.Close) + { + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None); + return; + } + else + { + var notificationBase = JsonSerializer.Deserialize(message, AspireServerService.JsonSerializerOptions); + if (notificationBase is null) + { + Console.WriteLine("Unexpected null SessionNotificationBase message"); + } + else if (notificationBase.NotificationType == NotificationType.ProcessRestarted || notificationBase.NotificationType == NotificationType.SessionTerminated) + { + var scn = JsonSerializer.Deserialize(message, AspireServerService.JsonSerializerOptions); + if (scn is null) + { + Assert.Fail("Unexpected null SessionChangeNotification message"); + } + else + { + callback.Invoke(scn); + } + } + else if (notificationBase.NotificationType == NotificationType.ServiceLogs) + { + var sessionLogs = JsonSerializer.Deserialize(message, AspireServerService.JsonSerializerOptions); + if (sessionLogs is null) + { + Assert.Fail("Unexpected null SessionLogsNotification message"); + } + else + { + callback.Invoke(sessionLogs); + } + } + } + } + catch + { + // This is expected if the connection is closed + return; + } + } + } + + private static HttpClient GetHttpClient((string serverAddress, string bearerToken, string certToken) tokens) + { + HttpClient client; + var serverCert = X509CertificateLoader.LoadCertificate(Convert.FromBase64String(tokens.certToken)); + var clientHandler = new HttpClientHandler() + { + ClientCertificateOptions = ClientCertificateOption.Manual, + SslProtocols = System.Security.Authentication.SslProtocols.None, + ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => + { + return cert?.Thumbprint == serverCert.Thumbprint; + } + }; + + client = new HttpClient(clientHandler); + client.BaseAddress = new Uri($"https://{tokens.serverAddress}"); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.bearerToken); + client.DefaultRequestHeaders.Add(HttpContextExtensions.DCPInstanceIDHeader, DcpId); + + return client; + } + + private async Task<(string, WebSocketMessageType)> GetSocketMsgAsync(ClientWebSocket client) + { + var rcvBuffer = new ArraySegment(new byte[2048]); + WebSocketReceiveResult rcvResult = await client.ReceiveAsync(rcvBuffer, CancellationToken.None); + if (rcvResult.MessageType == WebSocketMessageType.Text) + { + byte[] msgBytes = rcvBuffer.Skip(rcvBuffer.Offset).Take(rcvResult.Count).ToArray(); + return (Encoding.UTF8.GetString(msgBytes), rcvResult.MessageType); + } + + return (null, rcvResult.MessageType); + } + + private async Task GetAspireServer(Mocks mocks, bool waitForListening = true) + { + var ase = mocks.GetOrCreate(); + + var aspireServer = new AspireServerService(ase.Object, displayName: "Test server", Console.WriteLine); + + if (waitForListening) + { + await aspireServer.WaitForListeningAsync(); + } + + return aspireServer; + } + +#pragma warning disable IDE1006 // Naming Styles + internal class TestRunSessionRequestP4 + { + public string project_path { get; set; } = string.Empty; + public bool debug { get; set; } + public List env { get; set; } = new List(); + public List args { get; set; } = new List(); + public string launch_profile { get; set; } + public bool disable_launch_profile { get; set; } + } + + internal class TestRunSessionRequest + { + public TestRunSessionRequest(string projectPath, bool debugging, string launchProfile, bool disableLaunchProfile) + { + launch_configurations = new TestLaunchConfiguration[] + { + new() { + project_path = projectPath, + type = RunSessionRequest.ProjectLaunchConfigurationType, + mode= debugging? RunSessionRequest.DebugLaunchMode : RunSessionRequest.NoDebugLaunchMode, + launch_profile = launchProfile, + disable_launch_profile = disableLaunchProfile + } + }; + } + public TestLaunchConfiguration[] launch_configurations { get; set; } + public List env { get; set; } = new List(); + public List args { get; set; } = new List(); + + public TestRunSessionRequestP4 ToTestRunSessionRequestP4() + { + var launchConfig = launch_configurations[0]; + return new TestRunSessionRequestP4() + { + project_path = launchConfig.project_path, + debug = string.Equals(launchConfig.mode, RunSessionRequest.DebugLaunchMode, StringComparison.OrdinalIgnoreCase), + args = args, + env = env, + launch_profile = launchConfig.launch_profile, + disable_launch_profile = launchConfig.disable_launch_profile + }; + } + } + + internal class TestLaunchConfiguration + { + public string type { get; set; } = string.Empty; + public string project_path { get; set; } = string.Empty; + public string launch_profile { get; set; } + public bool disable_launch_profile { get; set; } + public string mode { get; set; } = string.Empty; + } + + internal class TestStopSessionRequest + { + public string session_id { get; set; } = string.Empty; + } +#pragma warning restore IDE1006 // Naming Styles +} + +internal static class AspireServerServiceExtensions +{ + public static async Task WaitForListeningAsync(this AspireServerService aspireServer) + { + string serverAddress = (await aspireServer.GetServerVariablesAsync()).serverAddress; + + // We need to wait on the port being available + await Helpers.CanConnectToPortAsync(new Uri($"http://{serverAddress}"), 5000, CancellationToken.None); + + } + + public static async Task<(string serverAddress, string bearerToken, string certToken)> GetServerVariablesAsync(this AspireServerService aspireServer) + { + var enVars = await aspireServer.GetServerConnectionEnvironmentAsync(CancellationToken.None); + return (enVars[0].Value, enVars[1].Value, enVars[2].Value); + } +} diff --git a/test/Microsoft.WebTools.AspireService.Tests/Microsoft.WebTools.AspireService.Tests.csproj b/test/Microsoft.WebTools.AspireService.Tests/Microsoft.WebTools.AspireService.Tests.csproj new file mode 100644 index 000000000000..927652b1cd10 --- /dev/null +++ b/test/Microsoft.WebTools.AspireService.Tests/Microsoft.WebTools.AspireService.Tests.csproj @@ -0,0 +1,18 @@ + + + + $(SdkTargetFramework) + Microsoft.WebTools.AspireServer.UnitTests + enable + Exe + false + + + + + + + + + + diff --git a/test/Microsoft.WebTools.AspireService.Tests/Mocks/Helpers.cs b/test/Microsoft.WebTools.AspireService.Tests/Mocks/Helpers.cs new file mode 100644 index 000000000000..19f6d17f15c3 --- /dev/null +++ b/test/Microsoft.WebTools.AspireService.Tests/Mocks/Helpers.cs @@ -0,0 +1,181 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using System.Net; +using System.Net.Sockets; + +namespace Microsoft.WebTools.AspireServer.UnitTests; + +public static class Helpers +{ + public static async Task CanConnectToPortAsync(Uri url, uint msToWait, CancellationToken cancelToken) + { + bool connected = false; + Socket? ipv4Socket = null; + Socket? ipv6Socket = null; + + // Create a "client" socket on any available port + try + { + TimeoutSpan timeout = new(msToWait); + if (Socket.OSSupportsIPv4) + { + try + { + ipv4Socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + ipv4Socket.Bind(new IPEndPoint(IPAddress.Any, 0)); + } + catch (SocketException) + { + if (ipv4Socket != null) + { + ipv4Socket.Close(); + ipv4Socket = null; + } + } + } + if (Socket.OSSupportsIPv6) + { + try + { + ipv6Socket = new Socket(AddressFamily.InterNetworkV6, SocketType.Stream, ProtocolType.Tcp); + ipv6Socket.Bind(new IPEndPoint(IPAddress.IPv6Any, 0)); + } + catch (SocketException) + { + if (ipv6Socket != null) + { + ipv6Socket.Close(); + ipv6Socket = null; + } + } + } + + // No sockets means we aren't connected + if (ipv6Socket == null && ipv4Socket == null) + { + return false; + } + + // If we have an IP address we use that otherwise assume loopback + IPEndPoint ipv4ServerEndPoint; + IPEndPoint ipv6ServerEndPoint; + if (IPAddress.TryParse(url.Host, out var ipAddress)) + { + ipv4ServerEndPoint = new IPEndPoint(ipAddress.AddressFamily == AddressFamily.InterNetwork ? ipAddress : IPAddress.Loopback, url.Port); + ipv6ServerEndPoint = new IPEndPoint(ipAddress.AddressFamily == AddressFamily.InterNetworkV6 ? ipAddress : IPAddress.IPv6Loopback, url.Port); + } + else + { + ipv4ServerEndPoint = new IPEndPoint(IPAddress.Loopback, url.Port); + ipv6ServerEndPoint = new IPEndPoint(IPAddress.IPv6Loopback, url.Port); + } + + // If a process is passed in, we bail if it has exited + while (!connected && !timeout.Expired) + { + cancelToken.ThrowIfCancellationRequested(); + if (ipv4Socket != null) + { + try + { + // Now use IOControl to set the calls non blocking + ipv4Socket.Blocking = false; + // Since we are non-blocking, the Connect should throw an error indicating + // it needs time to connect + ipv4Socket.Connect(ipv4ServerEndPoint); + } + catch (SocketException) + { + // Now ping retry and block for a millisecond timeout + ArrayList connectList = new ArrayList() {ipv4Socket}; + Socket.Select(null, connectList, null, 1000 /*microSecond -- in here, 1 milli-second*/); + if (connectList.Count == 1) + { + connected = true; + break; + } + } + finally + { + // TODO: why do we set the sockets back to blocking? + ipv4Socket.Blocking = true; + } + } + + // Now try IPV6 + if (ipv6Socket != null) + { + // Couldn't connect with IPV4, so try IPV6 + try + { + ipv6Socket.Blocking = false; + ipv6Socket.Connect(ipv6ServerEndPoint); + } + catch (SocketException) + { + // Ping retry + ArrayList connectList = new ArrayList() {ipv6Socket}; + Socket.Select(null, connectList, null, 1000 /*microSecond -- in here, 1 milli-second*/); + if (connectList.Count == 1) + { + connected = true; + break; + } + } + finally + { + // TODO: why do we set the sockets back to blocking? + ipv6Socket.Blocking = true; + } + } + + // Wait a bit and try again + await Task.Delay(20, cancelToken); + } + + } + finally + { + if (ipv4Socket != null) + { + ipv4Socket.Close(); + } + + if (ipv6Socket != null) + { + ipv6Socket.Close(); + } + } + + return connected; + } +} + +internal class TimeoutSpan +{ + private readonly long _duration; + private long _startingTickCount; + + public TimeoutSpan(long durationInMilliseconds) + { + // There are 10000 ticks in a millisecond so need to adjust accordingly + _duration = durationInMilliseconds * 10000; + Reset(); + } + + public bool Expired + { + get + { + return _duration != 0 && (DateTime.UtcNow.Ticks - _startingTickCount) > _duration; + } + } + + public void Reset() + { + // DateTime.UtcNow is way more efficient than DateTime.Now since it doesn't have to deal with locale, DST, etc + _startingTickCount = DateTime.UtcNow.Ticks; + } +} diff --git a/test/Microsoft.WebTools.AspireService.Tests/Mocks/IAspireServerEventsMock.cs b/test/Microsoft.WebTools.AspireService.Tests/Mocks/IAspireServerEventsMock.cs new file mode 100644 index 000000000000..3c805871fdf4 --- /dev/null +++ b/test/Microsoft.WebTools.AspireService.Tests/Mocks/IAspireServerEventsMock.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +using Microsoft.WebTools.AspireServer.Contracts; +using Moq; + +namespace Microsoft.WebTools.AspireServer.UnitTests; + +internal class IAspireServerEventsMock : MockFactory +{ + public IAspireServerEventsMock(Mocks mocks, MockBehavior? mockBehavior = null) + : base(mocks, mockBehavior) + { + } + + public IAspireServerEventsMock ImplementStartProjectAsync(string dcpId, string sessionId, Exception? ex = null) + { + MockObject.Setup(x => x.StartProjectAsync(dcpId, It.IsAny(), It.IsAny())) + .Returns(() => + { + if (ex is not null) + { + throw ex; + } + + return new ValueTask(sessionId); + }) + .Verifiable(); + return this; + } + + public IAspireServerEventsMock ImplementStopSessionAsync(string dcpId, string sessionId, bool exists, Exception? ex = null) + { + MockObject.Setup(x => x.StopSessionAsync(dcpId, sessionId, It.IsAny())) + .Returns(() => + { + if (ex is not null) + { + throw ex; + } + + return new ValueTask(exists); + }) + .Verifiable(); + return this; + } +} diff --git a/test/Microsoft.WebTools.AspireService.Tests/Mocks/IServiceProviderMock.cs b/test/Microsoft.WebTools.AspireService.Tests/Mocks/IServiceProviderMock.cs new file mode 100644 index 000000000000..c975028c392f --- /dev/null +++ b/test/Microsoft.WebTools.AspireService.Tests/Mocks/IServiceProviderMock.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Moq; + +namespace Microsoft.WebTools.AspireServer.UnitTests; + +internal class IServiceProviderMock : MockFactory +{ + public IServiceProviderMock(Mocks mocks, MockBehavior? mockBehavior = null) + : base(mocks, mockBehavior) + { + } + + public IServiceProviderMock ImplementService(Type type, object service) + { + MockObject.Setup(x => x.GetService(type)).Returns(service); + + return this; + } +} diff --git a/test/Microsoft.WebTools.AspireService.Tests/Mocks/MockFactory.cs b/test/Microsoft.WebTools.AspireService.Tests/Mocks/MockFactory.cs new file mode 100644 index 000000000000..ef9f4467013c --- /dev/null +++ b/test/Microsoft.WebTools.AspireService.Tests/Mocks/MockFactory.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Moq; + +namespace Microsoft.WebTools.AspireServer.UnitTests; + +public interface IMockFactory +{ + void Verify(); + object GetObject(); +} + +public class MockFactory : IMockFactory where T : class +{ + public MockFactory(Mocks mocks, MockBehavior? mockBehavior) + { + AllMocks = mocks; + MockObject = new Mock(mockBehavior ?? MockBehavior.Strict); + } + + protected Mocks AllMocks { get; } + public Mock MockObject { get; } + + public T Object => MockObject.Object; + + public virtual void Verify() + { + MockObject.VerifyAll(); + } + + public object GetObject() + { + return Object; + } +} diff --git a/test/Microsoft.WebTools.AspireService.Tests/Mocks/Mocks.cs b/test/Microsoft.WebTools.AspireService.Tests/Mocks/Mocks.cs new file mode 100644 index 000000000000..0b546ad5b20e --- /dev/null +++ b/test/Microsoft.WebTools.AspireService.Tests/Mocks/Mocks.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Moq; + +namespace Microsoft.WebTools.AspireServer.UnitTests; + +public class Mocks +{ + private readonly Dictionary _mockFactories = new(); + + public void Add(IMockFactory factory) + { + _mockFactories.Add(factory.GetType(), factory); + } + + public T GetOrCreate(MockBehavior? mockBehavior = null) where T : IMockFactory + { + if (_mockFactories.TryGetValue(typeof(T), out var factory)) + { + return (T)factory; + } + + var newMock = (IMockFactory?)Activator.CreateInstance(typeof(T), this, mockBehavior); + Debug.Assert(newMock != null); + Add(newMock); + return (T)newMock; + } + + public virtual void Verify() + { + foreach (var factory in _mockFactories) + { + factory.Value.Verify(); + } + } +} diff --git a/test/Microsoft.WebTools.AspireService.Tests/Properties/AssemblyInfo.cs b/test/Microsoft.WebTools.AspireService.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 000000000000..8b7c37cd90c4 --- /dev/null +++ b/test/Microsoft.WebTools.AspireService.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/test/Microsoft.WebTools.AspireService.Tests/Properties/launchSettings.json b/test/Microsoft.WebTools.AspireService.Tests/Properties/launchSettings.json new file mode 100644 index 000000000000..2d63805b76a3 --- /dev/null +++ b/test/Microsoft.WebTools.AspireService.Tests/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Microsoft.WebTools.AspireServer.Test": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:61653;http://localhost:61654" + } + } +} \ No newline at end of file diff --git a/test/Microsoft.WebTools.AspireService.Tests/RunSessionRequestTests.cs b/test/Microsoft.WebTools.AspireService.Tests/RunSessionRequestTests.cs new file mode 100644 index 000000000000..4012d02bf10c --- /dev/null +++ b/test/Microsoft.WebTools.AspireService.Tests/RunSessionRequestTests.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using Microsoft.WebTools.AspireServer.Models; + +namespace Microsoft.WebTools.AspireServer.UnitTests; + +public class RunSessionRequestTests +{ + [Fact] + public void RunSessionRequest_ToProjectLaunchRequest() + { + var runSessionReq = new RunSessionRequest() + { + Arguments = new string[] { "--someArg" }, + Environment = new EnvVar[] + { + new EnvVar { Name = "var1", Value = "value1"}, + new EnvVar { Name = "var2", Value = "value2"}, + }, + LaunchConfigurations = new LaunchConfiguration[] + { + new() { + ProjectPath = @"c:\test\Projects\project1.csproj", + LaunchType = RunSessionRequest.ProjectLaunchConfigurationType, + LaunchMode= RunSessionRequest.DebugLaunchMode, + LaunchProfile = "specificProfileName", + DisableLaunchProfile = true + } + } + }; + + var projectReq = runSessionReq.ToProjectLaunchInformation(); + + Assert.Equal(runSessionReq.Arguments[0], projectReq.Arguments.First()); + Assert.Equal(runSessionReq.Environment.Length, projectReq.Environment.Count()); + Assert.Equal(runSessionReq.Environment[0].Name, projectReq.Environment.First().Key); + Assert.Equal(runSessionReq.Environment[0].Value, projectReq.Environment.First().Value); + Assert.Equal(runSessionReq.LaunchConfigurations[0].ProjectPath, projectReq.ProjectPath); + Assert.True(projectReq.Debug); + Assert.Equal(runSessionReq.LaunchConfigurations[0].LaunchProfile, projectReq.LaunchProfile); + Assert.Equal(runSessionReq.LaunchConfigurations[0].DisableLaunchProfile, projectReq.DisableLaunchProfile); + } +}