From dfd75e939d49733fd8dcb0104a7b121671c467f6 Mon Sep 17 00:00:00 2001 From: Justin Kotalik Date: Mon, 2 Jul 2018 12:15:15 -0700 Subject: [PATCH] Add full IIS tests (#979) --- .appveyor.yml | 1 + .../IIS.Performance/PlaintextBenchmark.cs | 2 +- build/dependencies.props | 4 +- .../CommonLib/hostfxr_utility.cpp | 6 +- .../IISIntegration.FunctionalTests.csproj | 2 + .../Inprocess/IISTests.cs | 40 +++ .../Inprocess/TestServerTest.cs | 4 +- .../Utilities/FunctionalTestsBase.cs | 12 +- .../Utilities/IISApplication.cs | 310 ++++++++++++++++++ .../Utilities/IISDeployer.cs | 95 ++++++ .../Utilities/IISDeploymentResult.cs | 3 + ...pIfHostableWebCoreNotAvailibleAttribute.cs | 2 +- .../Utilities/SkipIfIISCannotRunAttribute.cs | 74 +++++ .../Utilities/TestIISUriHelper.cs | 84 +++++ .../Utilities/TestServer.cs | 2 +- 15 files changed, 629 insertions(+), 12 deletions(-) create mode 100644 test/IISIntegration.FunctionalTests/Inprocess/IISTests.cs create mode 100644 test/IISIntegration.FunctionalTests/Utilities/IISApplication.cs create mode 100644 test/IISIntegration.FunctionalTests/Utilities/IISDeployer.cs create mode 100644 test/IISIntegration.FunctionalTests/Utilities/SkipIfIISCannotRunAttribute.cs create mode 100644 test/IISIntegration.FunctionalTests/Utilities/TestIISUriHelper.cs diff --git a/.appveyor.yml b/.appveyor.yml index 8290b9bacd..f46dcc270f 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -8,6 +8,7 @@ branches: install: - ps: .\tools\update_schema.ps1 - git submodule update --init --recursive + - net start w3svc build_script: - ps: .\run.ps1 default-build clone_depth: 1 diff --git a/benchmarks/IIS.Performance/PlaintextBenchmark.cs b/benchmarks/IIS.Performance/PlaintextBenchmark.cs index ff79705756..8c6b4b2cd3 100644 --- a/benchmarks/IIS.Performance/PlaintextBenchmark.cs +++ b/benchmarks/IIS.Performance/PlaintextBenchmark.cs @@ -6,9 +6,9 @@ using System.Net.Http; using System.Text; using System.Threading.Tasks; using BenchmarkDotNet.Attributes; -using IISIntegration.FunctionalTests.Utilities; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Server.IISIntegration.FunctionalTests; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Server.IIS.Performance diff --git a/build/dependencies.props b/build/dependencies.props index 0736961427..106a216654 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -1,4 +1,4 @@ - + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) @@ -38,8 +38,10 @@ 2.2.0-preview1-26618-02 2.2.0-preview1-34530 15.6.1 + 11.1.0 2.0.3 4.6.0-preview1-26617-01 + 4.6.0-preview1-26617-01 4.6.0-preview1-26617-01 4.6.0-preview1-26617-01 4.6.0-preview1-26617-01 diff --git a/src/AspNetCoreModuleV2/CommonLib/hostfxr_utility.cpp b/src/AspNetCoreModuleV2/CommonLib/hostfxr_utility.cpp index 2ebc70966e..48eb39e045 100644 --- a/src/AspNetCoreModuleV2/CommonLib/hostfxr_utility.cpp +++ b/src/AspNetCoreModuleV2/CommonLib/hostfxr_utility.cpp @@ -334,7 +334,7 @@ HOSTFXR_UTILITY::GetAbsolutePathToDotnet( const fs::path & requestedPath ) { - WLOG_INFOF(L"Resolving absolute path do dotnet.exe from %s", requestedPath.c_str()); + WLOG_INFOF(L"Resolving absolute path to dotnet.exe from %s", requestedPath.c_str()); // // If we are given an absolute path to dotnet.exe, we are done @@ -368,7 +368,7 @@ HOSTFXR_UTILITY::GetAbsolutePathToDotnet( const auto dotnetViaWhere = InvokeWhereToFindDotnet(); if (dotnetViaWhere.has_value()) { - WLOG_INFOF(L"Found dotnet.exe wia where.exe invocation at %s", dotnetViaWhere.value().c_str()); + WLOG_INFOF(L"Found dotnet.exe via where.exe invocation at %s", dotnetViaWhere.value().c_str()); return dotnetViaWhere; } @@ -394,7 +394,7 @@ HOSTFXR_UTILITY::GetAbsolutePathToHostFxr( std::vector versionFolders; const auto hostFxrBase = dotnetPath.parent_path() / "host" / "fxr"; - WLOG_INFOF(L"Resolving absolute path do hostfxr.dll from %s", dotnetPath.c_str()); + WLOG_INFOF(L"Resolving absolute path to hostfxr.dll from %s", dotnetPath.c_str()); if (!is_directory(hostFxrBase)) { diff --git a/test/IISIntegration.FunctionalTests/IISIntegration.FunctionalTests.csproj b/test/IISIntegration.FunctionalTests/IISIntegration.FunctionalTests.csproj index 69861d7aa6..544cde26ee 100644 --- a/test/IISIntegration.FunctionalTests/IISIntegration.FunctionalTests.csproj +++ b/test/IISIntegration.FunctionalTests/IISIntegration.FunctionalTests.csproj @@ -36,7 +36,9 @@ + + diff --git a/test/IISIntegration.FunctionalTests/Inprocess/IISTests.cs b/test/IISIntegration.FunctionalTests/Inprocess/IISTests.cs new file mode 100644 index 0000000000..040f03fcad --- /dev/null +++ b/test/IISIntegration.FunctionalTests/Inprocess/IISTests.cs @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Server.IntegrationTesting; +using Microsoft.AspNetCore.Testing.xunit; +using Xunit; + +namespace Microsoft.AspNetCore.Server.IISIntegration.FunctionalTests +{ + [SkipIfIISCannotRun] + public class IISTests : FunctionalTestsBase + { + [ConditionalFact] + public Task HelloWorld_IIS_CoreClr_X64_Standalone() + { + return HelloWorld(RuntimeFlavor.CoreClr, ApplicationType.Standalone); + } + + [ConditionalFact] + public Task HelloWorld_IIS_CoreClr_X64_Portable() + { + return HelloWorld(RuntimeFlavor.CoreClr, ApplicationType.Portable); + } + + private async Task HelloWorld(RuntimeFlavor runtimeFlavor, ApplicationType applicationType) + { + var deploymentParameters = Helpers.GetBaseDeploymentParameters(); + deploymentParameters.ServerType = ServerType.IIS; + deploymentParameters.ApplicationType = applicationType; + + var deploymentResult = await DeployAsync(deploymentParameters); + + var response = await deploymentResult.RetryingHttpClient.GetAsync("HelloWorld"); + var responseText = await response.Content.ReadAsStringAsync(); + + Assert.Equal("Hello World", responseText); + } + } +} diff --git a/test/IISIntegration.FunctionalTests/Inprocess/TestServerTest.cs b/test/IISIntegration.FunctionalTests/Inprocess/TestServerTest.cs index 9c9f9918bb..4b4d3f25c8 100644 --- a/test/IISIntegration.FunctionalTests/Inprocess/TestServerTest.cs +++ b/test/IISIntegration.FunctionalTests/Inprocess/TestServerTest.cs @@ -1,8 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Diagnostics; -using System.Threading; using System.Threading.Tasks; using IISIntegration.FunctionalTests.Utilities; using Microsoft.AspNetCore.Http; @@ -11,7 +9,7 @@ using Microsoft.Extensions.Logging.Testing; using Xunit; using Xunit.Abstractions; -namespace IISIntegration.FunctionalTests.Inprocess +namespace Microsoft.AspNetCore.Server.IISIntegration.FunctionalTests { [SkipIfHostableWebCoreNotAvailible] public class TestServerTest: LoggedTest diff --git a/test/IISIntegration.FunctionalTests/Utilities/FunctionalTestsBase.cs b/test/IISIntegration.FunctionalTests/Utilities/FunctionalTestsBase.cs index 1e739b645a..a30387f0dd 100644 --- a/test/IISIntegration.FunctionalTests/Utilities/FunctionalTestsBase.cs +++ b/test/IISIntegration.FunctionalTests/Utilities/FunctionalTestsBase.cs @@ -21,10 +21,18 @@ namespace Microsoft.AspNetCore.Server.IntegrationTesting { if (!parameters.EnvironmentVariables.ContainsKey(DebugEnvironmentVariable)) { - // enable debug output parameters.EnvironmentVariables[DebugEnvironmentVariable] = "4"; } - _deployer = ApplicationDeployerFactory.Create(parameters, LoggerFactory); + + // Currently hosting throws if the Servertype = IIS. + if (parameters.ServerType == ServerType.IIS) + { + _deployer = new IISDeployer(parameters, LoggerFactory); + } + else + { + _deployer = ApplicationDeployerFactory.Create(parameters, LoggerFactory); + } var result = await _deployer.DeployAsync(); diff --git a/test/IISIntegration.FunctionalTests/Utilities/IISApplication.cs b/test/IISIntegration.FunctionalTests/Utilities/IISApplication.cs new file mode 100644 index 0000000000..013c24e376 --- /dev/null +++ b/test/IISIntegration.FunctionalTests/Utilities/IISApplication.cs @@ -0,0 +1,310 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Logging; +using Microsoft.Web.Administration; + +namespace Microsoft.AspNetCore.Server.IntegrationTesting +{ + /// + /// Represents the IIS website registered in the global applicationHost.config + /// + internal class IISApplication + { + private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(2); + private static readonly TimeSpan _retryDelay = TimeSpan.FromMilliseconds(100); + private readonly ServerManager _serverManager = new ServerManager(); + private readonly DeploymentParameters _deploymentParameters; + private readonly ILogger _logger; + private readonly string _ancmVersion; + private readonly object _syncLock = new object(); + private readonly string _apphostConfigBackupPath; + private static readonly string _apphostConfigPath = Path.Combine( + Environment.SystemDirectory, + "inetsrv", + "config", + "applicationhost.config"); + + public IISApplication(DeploymentParameters deploymentParameters, ILogger logger) + { + _deploymentParameters = deploymentParameters; + _logger = logger; + _ancmVersion = deploymentParameters.AncmVersion.ToString(); + WebSiteName = CreateTestSiteName(); + AppPoolName = $"{WebSiteName}Pool"; + _apphostConfigBackupPath = Path.Combine( + Environment.SystemDirectory, + "inetsrv", + "config", + $"applicationhost.config.{WebSiteName}backup"); + } + + public string WebSiteName { get; } + + public string AppPoolName { get; } + + public async Task StartIIS(Uri uri, string contentRoot) + { + // Backup currently deployed apphost.config file + using (_logger.BeginScope("StartIIS")) + { + AddTemporaryAppHostConfig(); + var port = uri.Port; + if (port == 0) + { + throw new NotSupportedException("Cannot set port 0 for IIS."); + } + + ConfigureAppPool(contentRoot); + + ConfigureSite(contentRoot, port); + + ConfigureAppHostConfig(contentRoot); + + if (_deploymentParameters.ApplicationType == ApplicationType.Portable) + { + ModifyAspNetCoreSectionInWebConfig("processPath", DotNetMuxer.MuxerPathOrDefault()); + } + + _serverManager.CommitChanges(); + + await WaitUntilSiteStarted(); + } + } + + private void ModifyAspNetCoreSectionInWebConfig(string key, string value) + { + var webConfigFile = Path.Combine(_deploymentParameters.PublishedApplicationRootPath, "web.config"); + var config = XDocument.Load(webConfigFile); + var element = config.Descendants("aspNetCore").FirstOrDefault(); + element.SetAttributeValue(key, value); + config.Save(webConfigFile); + } + + private async Task WaitUntilSiteStarted() + { + var sw = Stopwatch.StartNew(); + + while (sw.Elapsed < _timeout) + { + try + { + var site = _serverManager.Sites.FirstOrDefault(s => s.Name.Equals(WebSiteName)); + if (site.State == ObjectState.Started) + { + _logger.LogInformation($"Site {WebSiteName} has started."); + return; + } + else if (site.State != ObjectState.Starting) + { + _logger.LogInformation($"Site hasn't started with state: {site.State.ToString()}"); + var state = site.Start(); + _logger.LogInformation($"Tried to start site, state: {state.ToString()}"); + } + } + catch (COMException comException) + { + // Accessing the site.State property while the site + // is starting up returns the COMException + // The object identifier does not represent a valid object. + // (Exception from HRESULT: 0x800710D8) + // This also means the site is not started yet, so catch and retry + // after waiting. + _logger.LogWarning($"ComException: {comException.Message}"); + } + + await Task.Delay(_retryDelay); + } + + throw new TimeoutException($"IIS failed to start site."); + } + + public async Task StopAndDeleteAppPool() + { + if (string.IsNullOrEmpty(WebSiteName)) + { + return; + } + + RestoreAppHostConfig(); + + _serverManager.CommitChanges(); + + await WaitUntilSiteStopped(); + } + + private async Task WaitUntilSiteStopped() + { + var site = _serverManager.Sites.Where(element => element.Name == WebSiteName).FirstOrDefault(); + if (site == null) + { + return; + } + + var sw = Stopwatch.StartNew(); + + while (sw.Elapsed < _timeout) + { + try + { + if (site.State == ObjectState.Stopped) + { + _logger.LogInformation($"Site {WebSiteName} has stopped successfully."); + return; + } + } + catch (COMException) + { + // Accessing the site.State property while the site + // is shutdown down returns the COMException + return; + } + + _logger.LogWarning($"IIS has not stopped after {sw.Elapsed.TotalMilliseconds}"); + await Task.Delay(_retryDelay); + } + + throw new TimeoutException($"IIS failed to stop site {site}."); + } + + private void AddTemporaryAppHostConfig() + { + File.Copy(_apphostConfigPath, _apphostConfigBackupPath); + _logger.LogInformation($"Backed up {_apphostConfigPath} to {_apphostConfigBackupPath}"); + } + + private void RestoreAppHostConfig() + { + RetryHelper.RetryOperation( + () => File.Delete(_apphostConfigPath), + e => _logger.LogWarning($"Failed to delete directory : {e.Message}"), + retryCount: 3, + retryDelayMilliseconds: 100); + + File.Move(_apphostConfigBackupPath, _apphostConfigPath); + _logger.LogInformation($"Restored {_apphostConfigPath}."); + } + + private ApplicationPool ConfigureAppPool(string contentRoot) + { + var pool = _serverManager.ApplicationPools.Add(AppPoolName); + pool.ProcessModel.IdentityType = ProcessModelIdentityType.LocalSystem; + pool.ManagedRuntimeVersion = string.Empty; + var envCollection = pool.GetCollection("environmentVariables"); + + AddEnvironmentVariables(contentRoot, envCollection); + + _logger.LogInformation($"Configured AppPool {AppPoolName}"); + return pool; + } + + private void AddEnvironmentVariables(string contentRoot, ConfigurationElementCollection envCollection) + { + foreach (var tuple in _deploymentParameters.EnvironmentVariables) + { + AddEnvironmentVariableToAppPool(envCollection, tuple.Key, tuple.Value); + } + } + + private static void AddEnvironmentVariableToAppPool(ConfigurationElementCollection envCollection, string key, string value) + { + var addElement = envCollection.CreateElement("add"); + addElement["name"] = key; + addElement["value"] = value; + envCollection.Add(addElement); + } + + private Site ConfigureSite(string contentRoot, int port) + { + var site = _serverManager.Sites.Add(WebSiteName, contentRoot, port); + site.Applications.Single().ApplicationPoolName = AppPoolName; + _logger.LogInformation($"Configured Site {WebSiteName} with AppPool {AppPoolName}"); + return site; + } + + private Configuration ConfigureAppHostConfig(string dllRoot) + { + var config = _serverManager.GetApplicationHostConfiguration(); + + SetGlobalModuleSection(config, dllRoot); + + SetModulesSection(config); + + return config; + } + + private void SetGlobalModuleSection(Configuration config, string dllRoot) + { + var ancmFile = GetAncmLocation(dllRoot); + + var globalModulesSection = config.GetSection("system.webServer/globalModules"); + var globalConfigElement = globalModulesSection + .GetCollection() + .Where(element => (string)element["name"] == _ancmVersion) + .FirstOrDefault(); + + if (globalConfigElement == null) + { + _logger.LogInformation($"Could not find {_ancmVersion} section in global modules; creating section."); + var addElement = globalModulesSection.GetCollection().CreateElement("add"); + addElement["name"] = _ancmVersion; + addElement["image"] = ancmFile; + globalModulesSection.GetCollection().Add(addElement); + } + else + { + _logger.LogInformation($"Replacing {_ancmVersion} section in global modules with {ancmFile}"); + globalConfigElement["image"] = ancmFile; + } + } + + private void SetModulesSection(Configuration config) + { + var modulesSection = config.GetSection("system.webServer/modules"); + var moduleConfigElement = modulesSection.GetCollection().Where(element => (string)element["name"] == _ancmVersion).FirstOrDefault(); + if (moduleConfigElement == null) + { + _logger.LogInformation($"Could not find {_ancmVersion} section in modules; creating section."); + var moduleElement = modulesSection.GetCollection().CreateElement("add"); + moduleElement["name"] = _ancmVersion; + modulesSection.GetCollection().Add(moduleElement); + } + } + + private string CreateTestSiteName() + { + if (!string.IsNullOrEmpty(_deploymentParameters.SiteName)) + { + return $"{_deploymentParameters.SiteName}{DateTime.Now.ToString("yyyyMMddHHmmss")}"; + } + else + { + return $"testsite{DateTime.Now.ToString("yyyyMMddHHmmss")}"; + } + } + + private string GetAncmLocation(string dllRoot) + { + var arch = _deploymentParameters.RuntimeArchitecture == RuntimeArchitecture.x64 ? @"x64\aspnetcorev2.dll" : @"x86\aspnetcorev2.dll"; + var ancmFile = Path.Combine(dllRoot, arch); + if (!File.Exists(Environment.ExpandEnvironmentVariables(ancmFile))) + { + ancmFile = Path.Combine(dllRoot, "aspnetcorev2.dll"); + if (!File.Exists(Environment.ExpandEnvironmentVariables(ancmFile))) + { + throw new FileNotFoundException("AspNetCoreModule could not be found.", ancmFile); + } + } + + return ancmFile; + } + } +} diff --git a/test/IISIntegration.FunctionalTests/Utilities/IISDeployer.cs b/test/IISIntegration.FunctionalTests/Utilities/IISDeployer.cs new file mode 100644 index 0000000000..4f0d5252c9 --- /dev/null +++ b/test/IISIntegration.FunctionalTests/Utilities/IISDeployer.cs @@ -0,0 +1,95 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Server.IntegrationTesting +{ + /// + /// Deployer for IIS. + /// + public partial class IISDeployer : ApplicationDeployer + { + private IISApplication _application; + private CancellationTokenSource _hostShutdownToken = new CancellationTokenSource(); + + public IISDeployer(DeploymentParameters deploymentParameters, ILoggerFactory loggerFactory) + : base(deploymentParameters, loggerFactory) + { + } + + public override void Dispose() + { + if (_application != null) + { + _application.StopAndDeleteAppPool().GetAwaiter().GetResult(); + + TriggerHostShutdown(_hostShutdownToken); + } + + GetLogsFromFile($"{_application.WebSiteName}.txt"); + GetLogsFromFile("web.config"); + + CleanPublishedOutput(); + InvokeUserApplicationCleanup(); + + StopTimer(); + } + + public override async Task DeployAsync() + { + using (Logger.BeginScope("Deployment")) + { + StartTimer(); + + var contentRoot = string.Empty; + _application = new IISApplication(DeploymentParameters, Logger); + + // For now, only support using published output + DeploymentParameters.PublishApplicationBeforeDeployment = true; + + if (DeploymentParameters.PublishApplicationBeforeDeployment) + { + DotnetPublish(); + contentRoot = DeploymentParameters.PublishedApplicationRootPath; + } + + var uri = TestIISUriHelper.BuildTestUri(ServerType.IIS, DeploymentParameters.ApplicationBaseUriHint); + // To prevent modifying the IIS setup concurrently. + await _application.StartIIS(uri, contentRoot); + + // Warm up time for IIS setup. + Logger.LogInformation("Successfully finished IIS application directory setup."); + + return new DeploymentResult( + LoggerFactory, + DeploymentParameters, + applicationBaseUri: uri.ToString(), + contentRoot: contentRoot, + hostShutdownToken: _hostShutdownToken.Token + ); + } + } + + private void GetLogsFromFile(string file) + { + var arr = new string[0]; + + RetryHelper.RetryOperation(() => arr = File.ReadAllLines(Path.Combine(DeploymentParameters.PublishedApplicationRootPath, file)), + (ex) => Logger.LogError("Could not read log file"), + 5, + 200); + foreach (var line in arr) + { + Logger.LogInformation(line); + } + } + + } +} diff --git a/test/IISIntegration.FunctionalTests/Utilities/IISDeploymentResult.cs b/test/IISIntegration.FunctionalTests/Utilities/IISDeploymentResult.cs index 6ea25b89ec..8e39634f2b 100644 --- a/test/IISIntegration.FunctionalTests/Utilities/IISDeploymentResult.cs +++ b/test/IISIntegration.FunctionalTests/Utilities/IISDeploymentResult.cs @@ -1,3 +1,6 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + using System.Net.Http; using Microsoft.Extensions.Logging; diff --git a/test/IISIntegration.FunctionalTests/Utilities/SkipIfHostableWebCoreNotAvailibleAttribute.cs b/test/IISIntegration.FunctionalTests/Utilities/SkipIfHostableWebCoreNotAvailibleAttribute.cs index 046d430317..b21a087321 100644 --- a/test/IISIntegration.FunctionalTests/Utilities/SkipIfHostableWebCoreNotAvailibleAttribute.cs +++ b/test/IISIntegration.FunctionalTests/Utilities/SkipIfHostableWebCoreNotAvailibleAttribute.cs @@ -5,7 +5,7 @@ using System; using System.IO; using Microsoft.AspNetCore.Testing.xunit; -namespace IISIntegration.FunctionalTests.Utilities +namespace Microsoft.AspNetCore.Server.IISIntegration.FunctionalTests { [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)] public sealed class SkipIfHostableWebCoreNotAvailibleAttribute : Attribute, ITestCondition diff --git a/test/IISIntegration.FunctionalTests/Utilities/SkipIfIISCannotRunAttribute.cs b/test/IISIntegration.FunctionalTests/Utilities/SkipIfIISCannotRunAttribute.cs new file mode 100644 index 0000000000..aa6c3abd75 --- /dev/null +++ b/test/IISIntegration.FunctionalTests/Utilities/SkipIfIISCannotRunAttribute.cs @@ -0,0 +1,74 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Xml.Linq; +using Microsoft.AspNetCore.Server.IntegrationTesting; +using Microsoft.AspNetCore.Testing.xunit; + +namespace Microsoft.AspNetCore.Server.IISIntegration.FunctionalTests +{ + [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)] + public sealed class SkipIfIISCannotRunAttribute : Attribute, ITestCondition + { + private static readonly bool _isMet; + public static readonly string _skipReason; + + public bool IsMet => _isMet; + public string SkipReason => _skipReason; + + static SkipIfIISCannotRunAttribute() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + _skipReason = "IIS tests can only be run on Windows"; + return; + } + + var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + if (!principal.IsInRole(WindowsBuiltInRole.Administrator)) + { + _skipReason += "The current console is not running as admin."; + return; + } + + if (!File.Exists(Path.Combine(Environment.SystemDirectory, "inetsrv", "w3wp.exe"))) + { + _skipReason += "The machine does not have IIS installed."; + return; + } + + var ancmConfigPath = Path.Combine(Environment.SystemDirectory, "inetsrv", "config", "schema", "aspnetcore_schema_v2.xml"); + + if (!File.Exists(ancmConfigPath)) + { + _skipReason = "IIS Schema is not installed."; + return; + } + + XDocument ancmConfig; + + try + { + ancmConfig = XDocument.Load(ancmConfigPath); + } + catch + { + _skipReason = "Could not read ANCM schema configuration"; + return; + } + + _isMet = ancmConfig + .Root + .Descendants("attribute") + .Any(n => "hostingModel".Equals(n.Attribute("name")?.Value, StringComparison.Ordinal)); + + _skipReason = _isMet ? null : "IIS schema needs to be upgraded to support ANCM."; + } + } +} diff --git a/test/IISIntegration.FunctionalTests/Utilities/TestIISUriHelper.cs b/test/IISIntegration.FunctionalTests/Utilities/TestIISUriHelper.cs new file mode 100644 index 0000000000..b8c37ddd93 --- /dev/null +++ b/test/IISIntegration.FunctionalTests/Utilities/TestIISUriHelper.cs @@ -0,0 +1,84 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Net; +using System.Net.Sockets; + +namespace Microsoft.AspNetCore.Server.IntegrationTesting +{ + // Copied from Hosting for now https://github.com/aspnet/Hosting/blob/970bc8a30d66dd6894f8f662e5fdab9e68d57777/src/Microsoft.AspNetCore.Server.IntegrationTesting/Common/TestUriHelper.cs + internal static class TestIISUriHelper + { + internal static Uri BuildTestUri(ServerType serverType) + { + return BuildTestUri(serverType, hint: null); + } + + internal static Uri BuildTestUri(ServerType serverType, string hint) + { + // Assume status messages are enabled for Kestrel and disabled for all other servers. + return BuildTestUri(serverType, hint, statusMessagesEnabled: serverType == ServerType.Kestrel); + } + + internal static Uri BuildTestUri(ServerType serverType, string hint, bool statusMessagesEnabled) + { + if (string.IsNullOrEmpty(hint)) + { + if (serverType == ServerType.Kestrel && statusMessagesEnabled) + { + // Most functional tests use this codepath and should directly bind to dynamic port "0" and scrape + // the assigned port from the status message, which should be 100% reliable since the port is bound + // once and never released. Binding to dynamic port "0" on "localhost" (both IPv4 and IPv6) is not + // supported, so the port is only bound on "127.0.0.1" (IPv4). If a test explicitly requires IPv6, + // it should provide a hint URL with "localhost" (IPv4 and IPv6) or "[::1]" (IPv6-only). + return new UriBuilder("http", "127.0.0.1", 0).Uri; + } + else + { + // If the server type is not Kestrel, or status messages are disabled, there is no status message + // from which to scrape the assigned port, so the less reliable GetNextPort() must be used. The + // port is bound on "localhost" (both IPv4 and IPv6), since this is supported when using a specific + // (non-zero) port. + return new UriBuilder("http", "localhost", GetNextPort()).Uri; + } + } + else + { + var uriHint = new Uri(hint); + if (uriHint.Port == 0) + { + // Only a few tests use this codepath, so it's fine to use the less reliable GetNextPort() for simplicity. + // The tests using this codepath will be reviewed to see if they can be changed to directly bind to dynamic + // port "0" on "127.0.0.1" and scrape the assigned port from the status message (the default codepath). + return new UriBuilder(uriHint) { Port = GetNextPort() }.Uri; + } + else + { + // If the hint contains a specific port, return it unchanged. + return uriHint; + } + } + } + + // Copied from https://github.com/aspnet/KestrelHttpServer/blob/47f1db20e063c2da75d9d89653fad4eafe24446c/test/Microsoft.AspNetCore.Server.Kestrel.FunctionalTests/AddressRegistrationTests.cs#L508 + // + // This method is an attempt to safely get a free port from the OS. Most of the time, + // when binding to dynamic port "0" the OS increments the assigned port, so it's safe + // to re-use the assigned port in another process. However, occasionally the OS will reuse + // a recently assigned port instead of incrementing, which causes flaky tests with AddressInUse + // exceptions. This method should only be used when the application itself cannot use + // dynamic port "0" (e.g. IISExpress). Most functional tests using raw Kestrel + // (with status messages enabled) should directly bind to dynamic port "0" and scrape + // the assigned port from the status message, which should be 100% reliable since the port + // is bound once and never released. + internal static int GetNextPort() + { + using (var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)) + { + socket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + return ((IPEndPoint)socket.LocalEndPoint).Port; + } + } + } +} diff --git a/test/IISIntegration.FunctionalTests/Utilities/TestServer.cs b/test/IISIntegration.FunctionalTests/Utilities/TestServer.cs index 10f526f860..6cd61b1459 100644 --- a/test/IISIntegration.FunctionalTests/Utilities/TestServer.cs +++ b/test/IISIntegration.FunctionalTests/Utilities/TestServer.cs @@ -15,7 +15,7 @@ using Microsoft.AspNetCore.Server.IntegrationTesting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace IISIntegration.FunctionalTests.Utilities +namespace Microsoft.AspNetCore.Server.IISIntegration.FunctionalTests { public class TestServer: IDisposable, IStartup {