From 45b0b83997cf5369d1e6541952af4e35de31c694 Mon Sep 17 00:00:00 2001 From: Nate McMaster Date: Tue, 30 Oct 2018 16:39:06 -0700 Subject: [PATCH 1/6] Merge branch 'release/2.1' into release/2.2 \n\nCommit migrated from https://github.com/dotnet/extensions/commit/18fcffbd251abf944e4cc82a3c775882f687a1b2 --- src/ObjectPool/src/DefaultObjectPool.cs | 17 +- src/ObjectPool/test/DefaultObjectPoolTest.cs | 4 +- src/ObjectPool/test/ThreadingTest.cs | 80 ++++ .../AspNetCoreBenchmarkAttribute.cs | 66 ++- .../BenchmarkRunner/DefaultCoreConfig.cs | 4 + .../BenchmarkRunner/DefaultCoreDebugConfig.cs | 23 + .../DefaultCorePerfLabConfig.cs | 48 ++ .../DefaultCoreProfileConfig.cs | 32 ++ .../ParamsDisplayInfoColumn.cs | 26 ++ src/Shared/BenchmarkRunner/Program.cs | 32 +- .../CertificateManager.cs | 425 ++++++++++++++---- .../CertificatePurpose.cs | 3 +- .../EnsureCertificateResult.cs | 2 +- .../NonCapturingTimer/NonCapturingTimer.cs | 43 ++ src/Shared/Process/ProcessHelper.cs | 35 +- src/Shared/PropertyHelper/PropertyHelper.cs | 29 +- .../Shared.Tests/CertificateManagerTests.cs | 196 ++++---- .../test/Shared.Tests/DotNetMuxerTests.cs | 2 +- .../Shared.Tests/NonCapturingTimerTest.cs | 40 ++ .../test/Shared.Tests/PropertyHelperTest.cs | 32 ++ .../ThrowingLibrary/ThrowingLibrary.csproj | 1 + src/Testing/src/CultureReplacer.cs | 79 ++++ src/Testing/src/ExceptionAssertions.cs | 271 +++++++++++ src/Testing/src/HttpClientSlim.cs | 158 +++++++ .../src/Microsoft.AspNetCore.Testing.csproj | 33 ++ src/Testing/src/Properties/AssemblyInfo.cs | 6 + src/Testing/src/ReplaceCulture.cs | 70 +++ src/Testing/src/TaskExtensions.cs | 64 +++ src/Testing/src/TestPathUtilities.cs | 31 ++ src/Testing/src/TestPlatformHelper.cs | 23 + .../src/Tracing/CollectingEventListener.cs | 60 +++ src/Testing/src/Tracing/EventAssert.cs | 60 +++ .../src/Tracing/EventSourceTestBase.cs | 39 ++ .../EventSourceTestCollection.cs | 10 + .../src/xunit/ConditionalFactAttribute.cs | 15 + .../src/xunit/ConditionalFactDiscoverer.cs | 27 ++ .../src/xunit/ConditionalTheoryAttribute.cs | 15 + .../src/xunit/ConditionalTheoryDiscoverer.cs | 47 ++ src/Testing/src/xunit/DockerOnlyAttribute.cs | 38 ++ ...vironmentVariableSkipConditionAttribute.cs | 95 ++++ .../xunit/FrameworkSkipConditionAttribute.cs | 57 +++ src/Testing/src/xunit/IEnvironmentVariable.cs | 10 + src/Testing/src/xunit/ITestCondition.cs | 12 + .../src/xunit/MinimumOsVersionAttribute.cs | 111 +++++ .../src/xunit/OSSkipConditionAttribute.cs | 99 ++++ src/Testing/src/xunit/OperatingSystems.cs | 15 + src/Testing/src/xunit/RuntimeFrameworks.cs | 16 + src/Testing/src/xunit/SkippedTestCase.cs | 40 ++ src/Testing/src/xunit/TestMethodExtensions.cs | 34 ++ src/Testing/src/xunit/WindowsVersions.cs | 18 + .../test/CollectingEventListenerTest.cs | 87 ++++ src/Testing/test/ConditionalFactTest.cs | 60 +++ src/Testing/test/ConditionalTheoryTest.cs | 156 +++++++ src/Testing/test/DockerTests.cs | 21 + .../EnvironmentVariableSkipConditionTest.cs | 166 +++++++ src/Testing/test/ExceptionAssertTest.cs | 39 ++ src/Testing/test/HttpClientSlimTest.cs | 117 +++++ .../Microsoft.AspNetCore.Testing.Tests.csproj | 28 ++ .../test/OSSkipConditionAttributeTest.cs | 132 ++++++ src/Testing/test/OSSkipConditionTest.cs | 116 +++++ .../test/ReplaceCultureAttributeTest.cs | 66 +++ src/Testing/test/TaskExtensionsTest.cs | 18 + src/Testing/test/TestPathUtilitiesTest.cs | 31 ++ src/Testing/test/TestPlatformHelperTest.cs | 55 +++ 64 files changed, 3517 insertions(+), 268 deletions(-) create mode 100644 src/ObjectPool/test/ThreadingTest.cs create mode 100644 src/Shared/BenchmarkRunner/DefaultCoreDebugConfig.cs create mode 100644 src/Shared/BenchmarkRunner/DefaultCorePerfLabConfig.cs create mode 100644 src/Shared/BenchmarkRunner/DefaultCoreProfileConfig.cs create mode 100644 src/Shared/BenchmarkRunner/ParamsDisplayInfoColumn.cs create mode 100644 src/Shared/NonCapturingTimer/NonCapturingTimer.cs create mode 100644 src/Shared/test/Shared.Tests/NonCapturingTimerTest.cs create mode 100644 src/Testing/src/CultureReplacer.cs create mode 100644 src/Testing/src/ExceptionAssertions.cs create mode 100644 src/Testing/src/HttpClientSlim.cs create mode 100644 src/Testing/src/Microsoft.AspNetCore.Testing.csproj create mode 100644 src/Testing/src/Properties/AssemblyInfo.cs create mode 100644 src/Testing/src/ReplaceCulture.cs create mode 100644 src/Testing/src/TaskExtensions.cs create mode 100644 src/Testing/src/TestPathUtilities.cs create mode 100644 src/Testing/src/TestPlatformHelper.cs create mode 100644 src/Testing/src/Tracing/CollectingEventListener.cs create mode 100644 src/Testing/src/Tracing/EventAssert.cs create mode 100644 src/Testing/src/Tracing/EventSourceTestBase.cs create mode 100644 src/Testing/src/contentFiles/cs/netstandard2.0/EventSourceTestCollection.cs create mode 100644 src/Testing/src/xunit/ConditionalFactAttribute.cs create mode 100644 src/Testing/src/xunit/ConditionalFactDiscoverer.cs create mode 100644 src/Testing/src/xunit/ConditionalTheoryAttribute.cs create mode 100644 src/Testing/src/xunit/ConditionalTheoryDiscoverer.cs create mode 100644 src/Testing/src/xunit/DockerOnlyAttribute.cs create mode 100644 src/Testing/src/xunit/EnvironmentVariableSkipConditionAttribute.cs create mode 100644 src/Testing/src/xunit/FrameworkSkipConditionAttribute.cs create mode 100644 src/Testing/src/xunit/IEnvironmentVariable.cs create mode 100644 src/Testing/src/xunit/ITestCondition.cs create mode 100644 src/Testing/src/xunit/MinimumOsVersionAttribute.cs create mode 100644 src/Testing/src/xunit/OSSkipConditionAttribute.cs create mode 100644 src/Testing/src/xunit/OperatingSystems.cs create mode 100644 src/Testing/src/xunit/RuntimeFrameworks.cs create mode 100644 src/Testing/src/xunit/SkippedTestCase.cs create mode 100644 src/Testing/src/xunit/TestMethodExtensions.cs create mode 100644 src/Testing/src/xunit/WindowsVersions.cs create mode 100644 src/Testing/test/CollectingEventListenerTest.cs create mode 100644 src/Testing/test/ConditionalFactTest.cs create mode 100644 src/Testing/test/ConditionalTheoryTest.cs create mode 100644 src/Testing/test/DockerTests.cs create mode 100644 src/Testing/test/EnvironmentVariableSkipConditionTest.cs create mode 100644 src/Testing/test/ExceptionAssertTest.cs create mode 100644 src/Testing/test/HttpClientSlimTest.cs create mode 100644 src/Testing/test/Microsoft.AspNetCore.Testing.Tests.csproj create mode 100644 src/Testing/test/OSSkipConditionAttributeTest.cs create mode 100644 src/Testing/test/OSSkipConditionTest.cs create mode 100644 src/Testing/test/ReplaceCultureAttributeTest.cs create mode 100644 src/Testing/test/TaskExtensionsTest.cs create mode 100644 src/Testing/test/TestPathUtilitiesTest.cs create mode 100644 src/Testing/test/TestPlatformHelperTest.cs diff --git a/src/ObjectPool/src/DefaultObjectPool.cs b/src/ObjectPool/src/DefaultObjectPool.cs index 87825967ac..dcd7f1c715 100644 --- a/src/ObjectPool/src/DefaultObjectPool.cs +++ b/src/ObjectPool/src/DefaultObjectPool.cs @@ -42,8 +42,7 @@ namespace Microsoft.Extensions.ObjectPool public override T Get() { - T item = _firstItem; - + var item = _firstItem; if (item == null || Interlocked.CompareExchange(ref _firstItem, null, item) != item) { item = GetViaScan(); @@ -55,12 +54,10 @@ namespace Microsoft.Extensions.ObjectPool [MethodImpl(MethodImplOptions.AggressiveInlining)] private T GetViaScan() { - ObjectWrapper[] items = _items; - + var items = _items; for (var i = 0; i < items.Length; i++) { - T item = items[i]; - + var item = items[i].Element; if (item != null && Interlocked.CompareExchange(ref items[i].Element, null, item) == item) { return item; @@ -88,21 +85,17 @@ namespace Microsoft.Extensions.ObjectPool [MethodImpl(MethodImplOptions.AggressiveInlining)] private void ReturnViaScan(T obj) { - ObjectWrapper[] items = _items; - + var items = _items; for (var i = 0; i < items.Length && Interlocked.CompareExchange(ref items[i].Element, obj, null) != null; ++i) { } } + // PERF: the struct wrapper avoids array-covariance-checks from the runtime when assigning to elements of the array. [DebuggerDisplay("{Element}")] private struct ObjectWrapper { public T Element; - - public ObjectWrapper(T item) => Element = item; - - public static implicit operator T(ObjectWrapper wrapper) => wrapper.Element; } } } diff --git a/src/ObjectPool/test/DefaultObjectPoolTest.cs b/src/ObjectPool/test/DefaultObjectPoolTest.cs index ca14a3f53b..b44aa7e1c7 100644 --- a/src/ObjectPool/test/DefaultObjectPoolTest.cs +++ b/src/ObjectPool/test/DefaultObjectPoolTest.cs @@ -1,12 +1,10 @@ // 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.Collections.Generic; -using System.Threading.Tasks; using Xunit; -namespace Microsoft.Extensions.ObjectPool.Test +namespace Microsoft.Extensions.ObjectPool { public class DefaultObjectPoolTest { diff --git a/src/ObjectPool/test/ThreadingTest.cs b/src/ObjectPool/test/ThreadingTest.cs new file mode 100644 index 0000000000..541bc5ffd4 --- /dev/null +++ b/src/ObjectPool/test/ThreadingTest.cs @@ -0,0 +1,80 @@ +// 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; +using Xunit; + +namespace Microsoft.Extensions.ObjectPool +{ + public class ThreadingTest + { + private CancellationTokenSource _cts; + private DefaultObjectPool _pool; + private bool _foundError; + + [Fact] + public void RunThreadingTest() + { + _cts = new CancellationTokenSource(); + _pool = new DefaultObjectPool(new DefaultPooledObjectPolicy(), 10); + + var threads = new Thread[8]; + for (var i = 0; i < threads.Length; i++) + { + threads[i] = new Thread(Run); + } + + for (var i = 0; i < threads.Length; i++) + { + threads[i].Start(); + } + + // Run for 1000ms + _cts.CancelAfter(1000); + + // Wait for all threads to complete + for (var i = 0; i < threads.Length; i++) + { + threads[i].Join(); + } + + Assert.False(_foundError, "Race condition found. An item was shared across threads."); + } + + private void Run() + { + while (!_cts.IsCancellationRequested) + { + var obj = _pool.Get(); + if (obj.i != 0) + { + _foundError = true; + } + obj.i = 123; + + var obj2 = _pool.Get(); + if (obj2.i != 0) + { + _foundError = true; + } + obj2.i = 321; + + obj.Reset(); + _pool.Return(obj); + + obj2.Reset(); + _pool.Return(obj2); + } + } + + private class Item + { + public int i = 0; + + public void Reset() + { + i = 0; + } + } + } +} diff --git a/src/Shared/BenchmarkRunner/AspNetCoreBenchmarkAttribute.cs b/src/Shared/BenchmarkRunner/AspNetCoreBenchmarkAttribute.cs index a4044d1b5e..d16493a738 100644 --- a/src/Shared/BenchmarkRunner/AspNetCoreBenchmarkAttribute.cs +++ b/src/Shared/BenchmarkRunner/AspNetCoreBenchmarkAttribute.cs @@ -2,38 +2,72 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Linq; -using System.Reflection; -using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Running; +using System.Collections.Generic; using BenchmarkDotNet.Configs; -using BenchmarkDotNet.Jobs; -using BenchmarkDotNet.Toolchains.InProcess; namespace BenchmarkDotNet.Attributes { [AttributeUsage(AttributeTargets.Class | AttributeTargets.Assembly)] internal class AspNetCoreBenchmarkAttribute : Attribute, IConfigSource { - public static bool UseValidationConfig { get; set; } - - public Type ConfigType { get; } - public Type ValidationConfigType { get; } - - public AspNetCoreBenchmarkAttribute() : this(typeof(DefaultCoreConfig)) + public AspNetCoreBenchmarkAttribute() + : this(typeof(DefaultCoreConfig)) { } - public AspNetCoreBenchmarkAttribute(Type configType) : this(configType, typeof(DefaultCoreValidationConfig)) + public AspNetCoreBenchmarkAttribute(Type configType) + : this(configType, typeof(DefaultCoreValidationConfig)) { } public AspNetCoreBenchmarkAttribute(Type configType, Type validationConfigType) { - ConfigType = configType; - ValidationConfigType = validationConfigType; + ConfigTypes = new Dictionary() + { + { NamedConfiguration.Default, typeof(DefaultCoreConfig) }, + { NamedConfiguration.Validation, typeof(DefaultCoreValidationConfig) }, + { NamedConfiguration.Profile, typeof(DefaultCoreProfileConfig) }, + { NamedConfiguration.Debug, typeof(DefaultCoreDebugConfig) }, + { NamedConfiguration.PerfLab, typeof(DefaultCorePerfLabConfig) }, + }; + + if (configType != null) + { + ConfigTypes[NamedConfiguration.Default] = configType; + } + + if (validationConfigType != null) + { + ConfigTypes[NamedConfiguration.Validation] = validationConfigType; + } } - public IConfig Config => (IConfig) Activator.CreateInstance(UseValidationConfig ? ValidationConfigType : ConfigType, Array.Empty()); + public IConfig Config + { + get + { + if (!ConfigTypes.TryGetValue(ConfigName ?? NamedConfiguration.Default, out var configType)) + { + var message = $"Could not find a configuration matching {ConfigName}. " + + $"Known configurations: {string.Join(", ", ConfigTypes.Keys)}"; + throw new InvalidOperationException(message); + } + + return (IConfig)Activator.CreateInstance(configType, Array.Empty()); + } + } + + public Dictionary ConfigTypes { get; } + + public static string ConfigName { get; set; } = NamedConfiguration.Default; + + public static class NamedConfiguration + { + public static readonly string Default = "default"; + public static readonly string Validation = "validation"; + public static readonly string Profile = "profile"; + public static readonly string Debug = "debug"; + public static readonly string PerfLab = "perflab"; + } } } diff --git a/src/Shared/BenchmarkRunner/DefaultCoreConfig.cs b/src/Shared/BenchmarkRunner/DefaultCoreConfig.cs index a8d9d60536..5e2bafd506 100644 --- a/src/Shared/BenchmarkRunner/DefaultCoreConfig.cs +++ b/src/Shared/BenchmarkRunner/DefaultCoreConfig.cs @@ -28,7 +28,11 @@ namespace BenchmarkDotNet.Attributes Add(JitOptimizationsValidator.FailOnError); Add(Job.Core +#if NETCOREAPP2_1 .With(CsProjCoreToolchain.From(NetCoreAppSettings.NetCoreApp21)) +#else + .With(CsProjCoreToolchain.From(new NetCoreAppSettings("netcoreapp2.2", null, ".NET Core 2.2"))) +#endif .With(new GcMode { Server = true }) .With(RunStrategy.Throughput)); } diff --git a/src/Shared/BenchmarkRunner/DefaultCoreDebugConfig.cs b/src/Shared/BenchmarkRunner/DefaultCoreDebugConfig.cs new file mode 100644 index 0000000000..f51bed2db9 --- /dev/null +++ b/src/Shared/BenchmarkRunner/DefaultCoreDebugConfig.cs @@ -0,0 +1,23 @@ +// 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 BenchmarkDotNet.Configs; +using BenchmarkDotNet.Engines; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Loggers; +using BenchmarkDotNet.Validators; + +namespace BenchmarkDotNet.Attributes +{ + internal class DefaultCoreDebugConfig : ManualConfig + { + public DefaultCoreDebugConfig() + { + Add(ConsoleLogger.Default); + Add(JitOptimizationsValidator.DontFailOnError); + + Add(Job.InProcess + .With(RunStrategy.Throughput)); + } + } +} diff --git a/src/Shared/BenchmarkRunner/DefaultCorePerfLabConfig.cs b/src/Shared/BenchmarkRunner/DefaultCorePerfLabConfig.cs new file mode 100644 index 0000000000..5cc809e166 --- /dev/null +++ b/src/Shared/BenchmarkRunner/DefaultCorePerfLabConfig.cs @@ -0,0 +1,48 @@ +// 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 BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Engines; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Exporters.Csv; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Loggers; +using BenchmarkDotNet.Validators; + +namespace BenchmarkDotNet.Attributes +{ + internal class DefaultCorePerfLabConfig : ManualConfig + { + public DefaultCorePerfLabConfig() + { + Add(ConsoleLogger.Default); + + Add(MemoryDiagnoser.Default); + Add(StatisticColumn.OperationsPerSecond); + Add(new ParamsSummaryColumn()); + Add(DefaultColumnProviders.Statistics, DefaultColumnProviders.Diagnosers, DefaultColumnProviders.Target); + + // TODO: When upgrading to BDN 0.11.1, use Add(DefaultColumnProviders.Descriptor); + // DefaultColumnProviders.Target is deprecated + + Add(JitOptimizationsValidator.FailOnError); + + Add(Job.InProcess + .With(RunStrategy.Throughput)); + + Add(MarkdownExporter.GitHub); + + Add(new CsvExporter( + CsvSeparator.Comma, + new Reports.SummaryStyle + { + PrintUnitsInHeader = true, + PrintUnitsInContent = false, + TimeUnit = Horology.TimeUnit.Microsecond, + SizeUnit = SizeUnit.KB + })); + } + } +} diff --git a/src/Shared/BenchmarkRunner/DefaultCoreProfileConfig.cs b/src/Shared/BenchmarkRunner/DefaultCoreProfileConfig.cs new file mode 100644 index 0000000000..1b59cb89c5 --- /dev/null +++ b/src/Shared/BenchmarkRunner/DefaultCoreProfileConfig.cs @@ -0,0 +1,32 @@ +// 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 BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Engines; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Loggers; +using BenchmarkDotNet.Validators; + +namespace BenchmarkDotNet.Attributes +{ + internal class DefaultCoreProfileConfig : ManualConfig + { + public DefaultCoreProfileConfig() + { + Add(ConsoleLogger.Default); + Add(MarkdownExporter.GitHub); + + Add(MemoryDiagnoser.Default); + Add(StatisticColumn.OperationsPerSecond); + Add(DefaultColumnProviders.Instance); + + Add(JitOptimizationsValidator.FailOnError); + + Add(Job.InProcess + .With(RunStrategy.Throughput)); + } + } +} diff --git a/src/Shared/BenchmarkRunner/ParamsDisplayInfoColumn.cs b/src/Shared/BenchmarkRunner/ParamsDisplayInfoColumn.cs new file mode 100644 index 0000000000..b246e21c2e --- /dev/null +++ b/src/Shared/BenchmarkRunner/ParamsDisplayInfoColumn.cs @@ -0,0 +1,26 @@ +// 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 BenchmarkDotNet.Columns; +using BenchmarkDotNet.Reports; +using BenchmarkDotNet.Running; + +namespace BenchmarkDotNet.Attributes +{ + public class ParamsSummaryColumn : IColumn + { + public string Id => nameof(ParamsSummaryColumn); + public string ColumnName { get; } = "Params"; + public bool IsDefault(Summary summary, Benchmark benchmark) => false; + public string GetValue(Summary summary, Benchmark benchmark) => benchmark.Parameters.DisplayInfo; + public bool IsAvailable(Summary summary) => true; + public bool AlwaysShow => true; + public ColumnCategory Category => ColumnCategory.Params; + public int PriorityInCategory => 0; + public override string ToString() => ColumnName; + public bool IsNumeric => false; + public UnitType UnitType => UnitType.Dimensionless; + public string GetValue(Summary summary, Benchmark benchmark, ISummaryStyle style) => GetValue(summary, benchmark); + public string Legend => $"Summary of all parameter values"; + } +} \ No newline at end of file diff --git a/src/Shared/BenchmarkRunner/Program.cs b/src/Shared/BenchmarkRunner/Program.cs index 3297d5dae9..a1db1a50e8 100644 --- a/src/Shared/BenchmarkRunner/Program.cs +++ b/src/Shared/BenchmarkRunner/Program.cs @@ -2,15 +2,14 @@ // 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.Reflection; using System.Text; using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Running; using BenchmarkDotNet.Configs; -using BenchmarkDotNet.Jobs; -using BenchmarkDotNet.Toolchains.InProcess; +using BenchmarkDotNet.Running; namespace Microsoft.AspNetCore.BenchmarkDotNet.Runner { @@ -25,7 +24,7 @@ namespace Microsoft.AspNetCore.BenchmarkDotNet.Runner { BeforeMain(args); - CheckValidate(ref args); + AssignConfiguration(ref args); var summaries = BenchmarkSwitcher.FromAssembly(typeof(Program).GetTypeInfo().Assembly) .Run(args, ManualConfig.CreateEmpty()); @@ -66,16 +65,35 @@ namespace Microsoft.AspNetCore.BenchmarkDotNet.Runner return 1; } - private static void CheckValidate(ref string[] args) + private static void AssignConfiguration(ref string[] args) { var argsList = args.ToList(); if (argsList.Remove("--validate") || argsList.Remove("--validate-fast")) { + // Compat: support the old style of passing a config that is used by our build system. SuppressConsole(); - AspNetCoreBenchmarkAttribute.UseValidationConfig = true; + AspNetCoreBenchmarkAttribute.ConfigName = AspNetCoreBenchmarkAttribute.NamedConfiguration.Validation; + args = argsList.ToArray(); + return; + } + + var index = argsList.IndexOf("--config"); + if (index >= 0 && index < argsList.Count -1) + { + AspNetCoreBenchmarkAttribute.ConfigName = argsList[index + 1]; + argsList.RemoveAt(index + 1); + argsList.RemoveAt(index); + args = argsList.ToArray(); + return; } - args = argsList.ToArray(); + if (Debugger.IsAttached) + { + Console.WriteLine("Using the debug config since you are debugging. I hope that's OK!"); + Console.WriteLine("Specify a configuration with --config to override"); + AspNetCoreBenchmarkAttribute.ConfigName = AspNetCoreBenchmarkAttribute.NamedConfiguration.Debug; + return; + } } private static void SuppressConsole() diff --git a/src/Shared/CertificateGeneration/CertificateManager.cs b/src/Shared/CertificateGeneration/CertificateManager.cs index 4e2a0a9964..952cf7c36d 100644 --- a/src/Shared/CertificateGeneration/CertificateManager.cs +++ b/src/Shared/CertificateGeneration/CertificateManager.cs @@ -19,17 +19,12 @@ namespace Microsoft.AspNetCore.Certificates.Generation public const string AspNetHttpsOid = "1.3.6.1.4.1.311.84.1.1"; public const string AspNetHttpsOidFriendlyName = "ASP.NET Core HTTPS development certificate"; - public const string AspNetIdentityOid = "1.3.6.1.4.1.311.84.1.2"; - public const string AspNetIdentityOidFriendlyName = "ASP.NET Core Identity Json Web Token signing development certificate"; - private const string ServerAuthenticationEnhancedKeyUsageOid = "1.3.6.1.5.5.7.3.1"; private const string ServerAuthenticationEnhancedKeyUsageOidFriendlyName = "Server Authentication"; private const string LocalhostHttpsDnsName = "localhost"; private const string LocalhostHttpsDistinguishedName = "CN=" + LocalhostHttpsDnsName; - private const string IdentityDistinguishedName = "CN=Microsoft.AspNetCore.Identity.Signing"; - public const int RSAMinimumKeySizeInBits = 2048; private static readonly TimeSpan MaxRegexTimeout = TimeSpan.FromMinutes(1); @@ -37,7 +32,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation private const string MacOSSystemKeyChain = "/Library/Keychains/System.keychain"; private static readonly string MacOSUserKeyChain = Environment.GetEnvironmentVariable("HOME") + "/Library/Keychains/login.keychain-db"; private const string MacOSFindCertificateCommandLine = "security"; -#if NETCOREAPP2_0 || NETCOREAPP2_1 +#if NETCOREAPP2_0 || NETCOREAPP2_1 || NETCOREAPP2_2 private static readonly string MacOSFindCertificateCommandLineArgumentsFormat = "find-certificate -c {0} -a -Z -p " + MacOSSystemKeyChain; #endif private const string MacOSFindCertificateOutputRegex = "SHA-1 hash: ([0-9A-Z]+)"; @@ -46,7 +41,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation private const string MacOSDeleteCertificateCommandLine = "sudo"; private const string MacOSDeleteCertificateCommandLineArgumentsFormat = "security delete-certificate -Z {0} {1}"; private const string MacOSTrustCertificateCommandLine = "sudo"; -#if NETCOREAPP2_0 || NETCOREAPP2_1 +#if NETCOREAPP2_0 || NETCOREAPP2_1 || NETCOREAPP2_2 private static readonly string MacOSTrustCertificateCommandLineArguments = "security add-trusted-cert -d -r trustRoot -k " + MacOSSystemKeyChain + " "; #endif private const int UserCancelledErrorCode = 1223; @@ -56,8 +51,10 @@ namespace Microsoft.AspNetCore.Certificates.Generation StoreName storeName, StoreLocation location, bool isValid, - bool requireExportable = true) + bool requireExportable = true, + DiagnosticInformation diagnostics = null) { + diagnostics?.Debug($"Listing '{purpose.ToString()}' certificates on '{location}\\{storeName}'."); var certificates = new List(); try { @@ -70,28 +67,37 @@ namespace Microsoft.AspNetCore.Certificates.Generation { case CertificatePurpose.All: matchingCertificates = matchingCertificates - .Where(c => HasOid(c, AspNetHttpsOid) || HasOid(c, AspNetIdentityOid)); + .Where(c => HasOid(c, AspNetHttpsOid)); break; case CertificatePurpose.HTTPS: matchingCertificates = matchingCertificates .Where(c => HasOid(c, AspNetHttpsOid)); break; - case CertificatePurpose.Signing: - matchingCertificates = matchingCertificates - .Where(c => HasOid(c, AspNetIdentityOid)); - break; default: break; } + + diagnostics?.Debug(diagnostics.DescribeCertificates(matchingCertificates)); if (isValid) { // Ensure the certificate hasn't expired, has a private key and its exportable // (for container/unix scenarios). + diagnostics?.Debug("Checking certificates for validity."); var now = DateTimeOffset.Now; - matchingCertificates = matchingCertificates + var validCertificates = matchingCertificates .Where(c => c.NotBefore <= now && now <= c.NotAfter && - (!requireExportable || !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || IsExportable(c))); + (!requireExportable || !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || IsExportable(c))) + .ToArray(); + + var invalidCertificates = matchingCertificates.Except(validCertificates); + + diagnostics?.Debug("Listing valid certificates"); + diagnostics?.Debug(diagnostics.DescribeCertificates(validCertificates)); + diagnostics?.Debug("Listing invalid certificates"); + diagnostics?.Debug(diagnostics.DescribeCertificates(invalidCertificates)); + + matchingCertificates = validCertificates; } // We need to enumerate the certificates early to prevent dispoisng issues. @@ -123,7 +129,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation cngPrivateKey.Key.ExportPolicy == CngExportPolicies.AllowExport)); #else // Only check for RSA CryptoServiceProvider and do not fail in XPlat tooling as - // System.Security.Cryptography.Cng is not pat of the shared framework and we don't + // System.Security.Cryptography.Cng is not part of the shared framework and we don't // want to bring the dependency in on CLI scenarios. This functionality will be used // on CLI scenarios as part of the first run experience, so checking the exportability // of the certificate is not important. @@ -133,7 +139,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation #endif } - private void DisposeCertificates(IEnumerable disposables) + private static void DisposeCertificates(IEnumerable disposables) { foreach (var disposable in disposables) { @@ -147,9 +153,9 @@ namespace Microsoft.AspNetCore.Certificates.Generation } } -#if NETCOREAPP2_0 || NETCOREAPP2_1 +#if NETCOREAPP2_0 || NETCOREAPP2_1 || NETCOREAPP2_2 - public X509Certificate2 CreateAspNetCoreHttpsDevelopmentCertificate(DateTimeOffset notBefore, DateTimeOffset notAfter, string subjectOverride) + public X509Certificate2 CreateAspNetCoreHttpsDevelopmentCertificate(DateTimeOffset notBefore, DateTimeOffset notAfter, string subjectOverride, DiagnosticInformation diagnostics = null) { var subject = new X500DistinguishedName(subjectOverride ?? LocalhostHttpsDistinguishedName); var extensions = new List(); @@ -192,46 +198,6 @@ namespace Microsoft.AspNetCore.Certificates.Generation return certificate; } - public X509Certificate2 CreateApplicationTokenSigningDevelopmentCertificate(DateTimeOffset notBefore, DateTimeOffset notAfter, string subjectOverride) - { - var subject = new X500DistinguishedName(subjectOverride ?? IdentityDistinguishedName); - var extensions = new List(); - - var keyUsage = new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true); - var enhancedKeyUsage = new X509EnhancedKeyUsageExtension( - new OidCollection() { - new Oid( - ServerAuthenticationEnhancedKeyUsageOid, - ServerAuthenticationEnhancedKeyUsageOidFriendlyName) - }, - critical: true); - - var basicConstraints = new X509BasicConstraintsExtension( - certificateAuthority: false, - hasPathLengthConstraint: false, - pathLengthConstraint: 0, - critical: true); - - var aspNetIdentityExtension = new X509Extension( - new AsnEncodedData( - new Oid(AspNetIdentityOid, AspNetIdentityOidFriendlyName), - Encoding.ASCII.GetBytes(AspNetIdentityOidFriendlyName)), - critical: false); - - extensions.Add(basicConstraints); - extensions.Add(keyUsage); - extensions.Add(enhancedKeyUsage); - extensions.Add(aspNetIdentityExtension); - - var certificate = CreateSelfSignedCertificate(subject, extensions, notBefore, notAfter); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - certificate.FriendlyName = AspNetIdentityOidFriendlyName; - } - - return certificate; - } - public X509Certificate2 CreateSelfSignedCertificate( X500DistinguishedName subject, IEnumerable extensions, @@ -260,8 +226,9 @@ namespace Microsoft.AspNetCore.Certificates.Generation } } - public X509Certificate2 SaveCertificateInStore(X509Certificate2 certificate, StoreName name, StoreLocation location) + public X509Certificate2 SaveCertificateInStore(X509Certificate2 certificate, StoreName name, StoreLocation location, DiagnosticInformation diagnostics = null) { + diagnostics?.Debug("Saving the certificate into the certificate store."); var imported = certificate; if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { @@ -287,33 +254,67 @@ namespace Microsoft.AspNetCore.Certificates.Generation return imported; } - public void ExportCertificate(X509Certificate2 certificate, string path, bool includePrivateKey, string password) + public void ExportCertificate(X509Certificate2 certificate, string path, bool includePrivateKey, string password, DiagnosticInformation diagnostics = null) { - if (Path.GetDirectoryName(path) != "") + diagnostics?.Debug( + $"Exporting certificate to '{path}'", + includePrivateKey ? "The certificate will contain the private key" : "The certificate will not contain the private key"); + if (includePrivateKey && password == null) { - Directory.CreateDirectory(Path.GetDirectoryName(path)); + diagnostics?.Debug("No password was provided for the certificate."); } + var targetDirectoryPath = Path.GetDirectoryName(path); + if (targetDirectoryPath != "") + { + diagnostics?.Debug($"Ensuring that the directory for the target exported certificate path exists '{targetDirectoryPath}'"); + Directory.CreateDirectory(targetDirectoryPath); + } + + byte[] bytes; if (includePrivateKey) { - var bytes = certificate.Export(X509ContentType.Pkcs12, password); try { - File.WriteAllBytes(path, bytes); + diagnostics?.Debug($"Exporting the certificate including the private key."); + bytes = certificate.Export(X509ContentType.Pkcs12, password); } - finally + catch (Exception e) { - Array.Clear(bytes, 0, bytes.Length); + diagnostics?.Error($"Failed to export the certificate with the private key", e); + throw; } } else { - var bytes = certificate.Export(X509ContentType.Cert); + try + { + diagnostics?.Debug($"Exporting the certificate without the private key."); + bytes = certificate.Export(X509ContentType.Cert); + } + catch (Exception ex) + { + diagnostics?.Error($"Failed to export the certificate without the private key", ex); + throw; + } + } + try + { + diagnostics?.Debug($"Writing exported certificate to path '{path}'."); File.WriteAllBytes(path, bytes); } + catch (Exception ex) + { + diagnostics?.Error("Failed writing the certificate to the target path", ex); + throw; + } + finally + { + Array.Clear(bytes, 0, bytes.Length); + } } - public void TrustCertificate(X509Certificate2 certificate) + public void TrustCertificate(X509Certificate2 certificate, DiagnosticInformation diagnostics = null) { // Strip certificate of the private key if any. var publicCertificate = new X509Certificate2(certificate.Export(X509ContentType.Cert)); @@ -322,21 +323,24 @@ namespace Microsoft.AspNetCore.Certificates.Generation { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - TrustCertificateOnWindows(certificate, publicCertificate); + diagnostics?.Debug("Trusting the certificate on Windows."); + TrustCertificateOnWindows(certificate, publicCertificate, diagnostics); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - TrustCertificateOnMac(publicCertificate); + diagnostics?.Debug("Trusting the certificate on MAC."); + TrustCertificateOnMac(publicCertificate, diagnostics); } } } - private void TrustCertificateOnMac(X509Certificate2 publicCertificate) + private void TrustCertificateOnMac(X509Certificate2 publicCertificate, DiagnosticInformation diagnostics) { var tmpFile = Path.GetTempFileName(); try { ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null); + diagnostics?.Debug("Running the trust command on Mac OS"); using (var process = Process.Start(MacOSTrustCertificateCommandLine, MacOSTrustCertificateCommandLineArguments + tmpFile)) { process.WaitForExit(); @@ -362,19 +366,29 @@ namespace Microsoft.AspNetCore.Certificates.Generation } } - private static void TrustCertificateOnWindows(X509Certificate2 certificate, X509Certificate2 publicCertificate) + private static void TrustCertificateOnWindows(X509Certificate2 certificate, X509Certificate2 publicCertificate, DiagnosticInformation diagnostics = null) { publicCertificate.FriendlyName = certificate.FriendlyName; using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser)) { store.Open(OpenFlags.ReadWrite); + var existing = store.Certificates.Find(X509FindType.FindByThumbprint, publicCertificate.Thumbprint, validOnly: false); + if (existing.Count > 0) + { + diagnostics?.Debug("Certificate already trusted. Skipping trust step."); + DisposeCertificates(existing.OfType()); + return; + } + try { + diagnostics?.Debug("Adding certificate to the store."); store.Add(publicCertificate); } catch (CryptographicException exception) when (exception.HResult == UserCancelledErrorCode) { + diagnostics?.Debug("User cancelled the trust prompt."); throw new UserCancelledTrustException(); } store.Close(); @@ -437,6 +451,30 @@ namespace Microsoft.AspNetCore.Certificates.Generation } } + public DiagnosticInformation CleanupHttpsCertificates2(string subject = LocalhostHttpsDistinguishedName) + { + return CleanupCertificates2(CertificatePurpose.HTTPS, subject); + } + + public DiagnosticInformation CleanupCertificates2(CertificatePurpose purpose, string subject) + { + var diagnostics = new DiagnosticInformation(); + // On OS X we don't have a good way to manage trusted certificates in the system keychain + // so we do everything by invoking the native toolchain. + // This has some limitations, like for example not being able to identify our custom OID extension. For that + // matter, when we are cleaning up certificates on the machine, we start by removing the trusted certificates. + // To do this, we list the certificates that we can identify on the current user personal store and we invoke + // the native toolchain to remove them from the sytem keychain. Once we have removed the trusted certificates, + // we remove the certificates from the local user store to finish up the cleanup. + var certificates = ListCertificates(purpose, StoreName.My, StoreLocation.CurrentUser, isValid: false, requireExportable: true, diagnostics); + foreach (var certificate in certificates) + { + RemoveCertificate(certificate, RemoveLocations.All, diagnostics); + } + + return diagnostics; + } + public void RemoveAllCertificates(CertificatePurpose purpose, StoreName storeName, StoreLocation storeLocation, string subject = null) { var certificates = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? @@ -454,32 +492,33 @@ namespace Microsoft.AspNetCore.Certificates.Generation DisposeCertificates(certificates); } - private void RemoveCertificate(X509Certificate2 certificate, RemoveLocations locations) + private void RemoveCertificate(X509Certificate2 certificate, RemoveLocations locations, DiagnosticInformation diagnostics = null) { switch (locations) { case RemoveLocations.Undefined: throw new InvalidOperationException($"'{nameof(RemoveLocations.Undefined)}' is not a valid location."); case RemoveLocations.Local: - RemoveCertificateFromUserStore(certificate); + RemoveCertificateFromUserStore(certificate, diagnostics); break; case RemoveLocations.Trusted when !RuntimeInformation.IsOSPlatform(OSPlatform.Linux): - RemoveCertificateFromTrustedRoots(certificate); + RemoveCertificateFromTrustedRoots(certificate, diagnostics); break; case RemoveLocations.All: if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { - RemoveCertificateFromTrustedRoots(certificate); + RemoveCertificateFromTrustedRoots(certificate, diagnostics); } - RemoveCertificateFromUserStore(certificate); + RemoveCertificateFromUserStore(certificate, diagnostics); break; default: throw new InvalidOperationException("Invalid location."); } } - private static void RemoveCertificateFromUserStore(X509Certificate2 certificate) + private static void RemoveCertificateFromUserStore(X509Certificate2 certificate, DiagnosticInformation diagnostics) { + diagnostics?.Debug($"Trying to remove certificate with thumbprint '{certificate.Thumbprint}' from certificate store '{StoreLocation.CurrentUser}\\{StoreName.My}'."); using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) { store.Open(OpenFlags.ReadWrite); @@ -492,8 +531,9 @@ namespace Microsoft.AspNetCore.Certificates.Generation } } - private void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate) + private void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate, DiagnosticInformation diagnostics) { + diagnostics?.Debug($"Trying to remove certificate with thumbprint '{certificate.Thumbprint}' from certificate store '{StoreLocation.CurrentUser}\\{StoreName.Root}'."); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser)) @@ -501,9 +541,13 @@ namespace Microsoft.AspNetCore.Certificates.Generation store.Open(OpenFlags.ReadWrite); var matching = store.Certificates .OfType() - .Single(c => c.SerialNumber == certificate.SerialNumber); + .SingleOrDefault(c => c.SerialNumber == certificate.SerialNumber); + + if (matching != null) + { + store.Remove(matching); + } - store.Remove(matching); store.Close(); } } @@ -513,10 +557,12 @@ namespace Microsoft.AspNetCore.Certificates.Generation { try { + diagnostics?.Debug("Trying to remove the certificate trust rule."); RemoveCertificateTrustRule(certificate); } catch { + diagnostics?.Debug("Failed to remove the certificate trust rule."); // We don't care if we fail to remove the trust rule if // for some reason the certificate became untrusted. // The delete command will fail if the certificate is @@ -524,6 +570,10 @@ namespace Microsoft.AspNetCore.Certificates.Generation } RemoveCertificateFromKeyChain(MacOSSystemKeyChain, certificate); } + else + { + diagnostics?.Debug("The certificate was not trusted."); + } } } @@ -601,18 +651,6 @@ namespace Microsoft.AspNetCore.Certificates.Generation return EnsureValidCertificateExists(notBefore, notAfter, CertificatePurpose.HTTPS, path, trust, includePrivateKey, password, subject); } - public EnsureCertificateResult EnsureAspNetCoreApplicationTokensDevelopmentCertificate( - DateTimeOffset notBefore, - DateTimeOffset notAfter, - string path = null, - bool trust = false, - bool includePrivateKey = false, - string password = null, - string subject = IdentityDistinguishedName) - { - return EnsureValidCertificateExists(notBefore, notAfter, CertificatePurpose.Signing, path, trust, includePrivateKey, password, subject); - } - public EnsureCertificateResult EnsureValidCertificateExists( DateTimeOffset notBefore, DateTimeOffset notAfter, @@ -652,9 +690,6 @@ namespace Microsoft.AspNetCore.Certificates.Generation case CertificatePurpose.HTTPS: certificate = CreateAspNetCoreHttpsDevelopmentCertificate(notBefore, notAfter, subjectOverride); break; - case CertificatePurpose.Signing: - certificate = CreateApplicationTokenSigningDevelopmentCertificate(notBefore, notAfter, subjectOverride); - break; default: throw new InvalidOperationException("The certificate must have a purpose."); } @@ -704,6 +739,143 @@ namespace Microsoft.AspNetCore.Certificates.Generation return result; } + // This is just to avoid breaking changes across repos. + // Will be renamed back to EnsureAspNetCoreHttpsDevelopmentCertificate once updates are made elsewhere. + public DetailedEnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate2( + DateTimeOffset notBefore, + DateTimeOffset notAfter, + string path = null, + bool trust = false, + bool includePrivateKey = false, + string password = null, + string subject = LocalhostHttpsDistinguishedName) + { + return EnsureValidCertificateExists2(notBefore, notAfter, CertificatePurpose.HTTPS, path, trust, includePrivateKey, password, subject); + } + + public DetailedEnsureCertificateResult EnsureValidCertificateExists2( + DateTimeOffset notBefore, + DateTimeOffset notAfter, + CertificatePurpose purpose, + string path, + bool trust, + bool includePrivateKey, + string password, + string subject) + { + if (purpose == CertificatePurpose.All) + { + throw new ArgumentException("The certificate must have a specific purpose."); + } + + var result = new DetailedEnsureCertificateResult(); + + var certificates = ListCertificates(purpose, StoreName.My, StoreLocation.CurrentUser, isValid: true, requireExportable: true, result.Diagnostics).Concat( + ListCertificates(purpose, StoreName.My, StoreLocation.LocalMachine, isValid: true, requireExportable: true, result.Diagnostics)); + + var filteredCertificates = subject == null ? certificates : certificates.Where(c => c.Subject == subject); + if (subject != null) + { + var excludedCertificates = certificates.Except(filteredCertificates); + + result.Diagnostics.Debug($"Filtering found certificates to those with a subject equal to '{subject}'"); + result.Diagnostics.Debug(result.Diagnostics.DescribeCertificates(filteredCertificates)); + result.Diagnostics.Debug($"Listing certificates excluded from consideration."); + result.Diagnostics.Debug(result.Diagnostics.DescribeCertificates(excludedCertificates)); + } + else + { + result.Diagnostics.Debug("Skipped filtering certificates by subject."); + } + + certificates = filteredCertificates; + + result.ResultCode = EnsureCertificateResult.Succeeded; + + X509Certificate2 certificate = null; + if (certificates.Count() > 0) + { + result.Diagnostics.Debug("Found valid certificates present on the machine."); + result.Diagnostics.Debug(result.Diagnostics.DescribeCertificates(certificates)); + certificate = certificates.First(); + result.Diagnostics.Debug("Selected certificate"); + result.Diagnostics.Debug(result.Diagnostics.DescribeCertificates(certificate)); + result.ResultCode = EnsureCertificateResult.ValidCertificatePresent; + } + else + { + result.Diagnostics.Debug("No valid certificates present on this machine. Trying to create one."); + try + { + switch (purpose) + { + case CertificatePurpose.All: + throw new InvalidOperationException("The certificate must have a specific purpose."); + case CertificatePurpose.HTTPS: + certificate = CreateAspNetCoreHttpsDevelopmentCertificate(notBefore, notAfter, subject, result.Diagnostics); + break; + default: + throw new InvalidOperationException("The certificate must have a purpose."); + } + } + catch (Exception e) + { + result.Diagnostics.Error("Error creating the certificate.", e); + result.ResultCode = EnsureCertificateResult.ErrorCreatingTheCertificate; + return result; + } + + try + { + certificate = SaveCertificateInStore(certificate, StoreName.My, StoreLocation.CurrentUser, result.Diagnostics); + } + catch (Exception e) + { + result.Diagnostics.Error($"Error saving the certificate in the certificate store '{StoreLocation.CurrentUser}\\{StoreName.My}'.", e); + result.ResultCode = EnsureCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore; + return result; + } + } + if (path != null) + { + result.Diagnostics.Debug("Trying to export the certificate."); + result.Diagnostics.Debug(result.Diagnostics.DescribeCertificates(certificate)); + try + { + ExportCertificate(certificate, path, includePrivateKey, password, result.Diagnostics); + } + catch (Exception e) + { + result.Diagnostics.Error("An error ocurred exporting the certificate.", e); + result.ResultCode = EnsureCertificateResult.ErrorExportingTheCertificate; + return result; + } + } + + if ((RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) && trust) + { + try + { + result.Diagnostics.Debug("Trying to export the certificate."); + TrustCertificate(certificate, result.Diagnostics); + } + catch (UserCancelledTrustException) + { + result.Diagnostics.Error("The user cancelled trusting the certificate.", null); + result.ResultCode = EnsureCertificateResult.UserCancelledTrustStep; + return result; + } + catch (Exception e) + { + result.Diagnostics.Error("There was an error trusting the certificate.", e); + result.ResultCode = EnsureCertificateResult.FailedToTrustTheCertificate; + return result; + } + } + + return result; + } + private class UserCancelledTrustException : Exception { } @@ -715,6 +887,65 @@ namespace Microsoft.AspNetCore.Certificates.Generation Trusted, All } + + internal class DetailedEnsureCertificateResult + { + public EnsureCertificateResult ResultCode { get; set; } + public DiagnosticInformation Diagnostics { get; set; } = new DiagnosticInformation(); + } #endif + + internal class DiagnosticInformation + { + public IList Messages { get; } = new List(); + + public IList Exceptions { get; } = new List(); + + internal void Debug(params string[] messages) + { + foreach (var message in messages) + { + Messages.Add(message); + } + } + + internal string[] DescribeCertificates(params X509Certificate2[] certificates) + { + return DescribeCertificates(certificates.AsEnumerable()); + } + + internal string[] DescribeCertificates(IEnumerable certificates) + { + var result = new List(); + result.Add($"'{certificates.Count()}' found matching the criteria."); + result.Add($"SUBJECT - THUMBPRINT - NOT BEFORE - EXPIRES - HAS PRIVATE KEY"); + foreach (var certificate in certificates) + { + result.Add(DescribeCertificate(certificate)); + } + + return result.ToArray(); + } + + private static string DescribeCertificate(X509Certificate2 certificate) => + $"{certificate.Subject} - {certificate.Thumbprint} - {certificate.NotBefore} - {certificate.NotAfter} - {certificate.HasPrivateKey}"; + + internal void Error(string preamble, Exception e) + { + Messages.Add(preamble); + if (Exceptions.Count > 0 && Exceptions[Exceptions.Count - 1] == e) + { + return; + } + + var ex = e; + while (ex != null) + { + Messages.Add("Exception message: " + ex.Message); + ex = ex.InnerException; + } + + } + } } } \ No newline at end of file diff --git a/src/Shared/CertificateGeneration/CertificatePurpose.cs b/src/Shared/CertificateGeneration/CertificatePurpose.cs index 1ad1a6d79b..7b3231f80d 100644 --- a/src/Shared/CertificateGeneration/CertificatePurpose.cs +++ b/src/Shared/CertificateGeneration/CertificatePurpose.cs @@ -6,7 +6,6 @@ namespace Microsoft.AspNetCore.Certificates.Generation internal enum CertificatePurpose { All, - HTTPS, - Signing + HTTPS } } \ No newline at end of file diff --git a/src/Shared/CertificateGeneration/EnsureCertificateResult.cs b/src/Shared/CertificateGeneration/EnsureCertificateResult.cs index d3c86ce05d..84c495249d 100644 --- a/src/Shared/CertificateGeneration/EnsureCertificateResult.cs +++ b/src/Shared/CertificateGeneration/EnsureCertificateResult.cs @@ -1,7 +1,7 @@ // 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. -#if NETCOREAPP2_0 || NETCOREAPP2_1 +#if NETCOREAPP2_0 || NETCOREAPP2_1 || NETCOREAPP2_2 namespace Microsoft.AspNetCore.Certificates.Generation { diff --git a/src/Shared/NonCapturingTimer/NonCapturingTimer.cs b/src/Shared/NonCapturingTimer/NonCapturingTimer.cs new file mode 100644 index 0000000000..6f54b2db47 --- /dev/null +++ b/src/Shared/NonCapturingTimer/NonCapturingTimer.cs @@ -0,0 +1,43 @@ +// 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.Threading; + +namespace Microsoft.Extensions.Internal +{ + // A convenience API for interacting with System.Threading.Timer in a way + // that doesn't capture the ExecutionContext. We should be using this (or equivalent) + // everywhere we use timers to avoid rooting any values stored in asynclocals. + internal static class NonCapturingTimer + { + public static Timer Create(TimerCallback callback, object state, TimeSpan dueTime, TimeSpan period) + { + if (callback == null) + { + throw new ArgumentNullException(nameof(callback)); + } + + // Don't capture the current ExecutionContext and its AsyncLocals onto the timer + bool restoreFlow = false; + try + { + if (!ExecutionContext.IsFlowSuppressed()) + { + ExecutionContext.SuppressFlow(); + restoreFlow = true; + } + + return new Timer(callback, state, dueTime, period); + } + finally + { + // Restore the current ExecutionContext + if (restoreFlow) + { + ExecutionContext.RestoreFlow(); + } + } + } + } +} diff --git a/src/Shared/Process/ProcessHelper.cs b/src/Shared/Process/ProcessHelper.cs index cf42a7e3a7..c6cbd1f970 100644 --- a/src/Shared/Process/ProcessHelper.cs +++ b/src/Shared/Process/ProcessHelper.cs @@ -14,44 +14,40 @@ namespace Microsoft.Extensions.Internal private static readonly bool _isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); private static readonly TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30); - public static void KillTree(this Process process) - { - process.KillTree(_defaultTimeout); - } + public static void KillTree(this Process process) => process.KillTree(_defaultTimeout); public static void KillTree(this Process process, TimeSpan timeout) { - string stdout; + var pid = process.Id; if (_isWindows) { RunProcessAndWaitForExit( "taskkill", - $"/T /F /PID {process.Id}", + $"/T /F /PID {pid}", timeout, - out stdout); + out var _); } else { var children = new HashSet(); - GetAllChildIdsUnix(process.Id, children, timeout); + GetAllChildIdsUnix(pid, children, timeout); foreach (var childId in children) { KillProcessUnix(childId, timeout); } - KillProcessUnix(process.Id, timeout); + KillProcessUnix(pid, timeout); } } private static void GetAllChildIdsUnix(int parentId, ISet children, TimeSpan timeout) { - string stdout; - var exitCode = RunProcessAndWaitForExit( + RunProcessAndWaitForExit( "pgrep", $"-P {parentId}", timeout, - out stdout); + out var stdout); - if (exitCode == 0 && !string.IsNullOrEmpty(stdout)) + if (!string.IsNullOrEmpty(stdout)) { using (var reader = new StringReader(stdout)) { @@ -63,8 +59,7 @@ namespace Microsoft.Extensions.Internal return; } - int id; - if (int.TryParse(text, out id)) + if (int.TryParse(text, out var id)) { children.Add(id); // Recursively get the children @@ -77,22 +72,22 @@ namespace Microsoft.Extensions.Internal private static void KillProcessUnix(int processId, TimeSpan timeout) { - string stdout; RunProcessAndWaitForExit( "kill", $"-TERM {processId}", timeout, - out stdout); + out var stdout); } - private static int RunProcessAndWaitForExit(string fileName, string arguments, TimeSpan timeout, out string stdout) + private static void RunProcessAndWaitForExit(string fileName, string arguments, TimeSpan timeout, out string stdout) { var startInfo = new ProcessStartInfo { FileName = fileName, Arguments = arguments, RedirectStandardOutput = true, - UseShellExecute = false + RedirectStandardError = true, + UseShellExecute = false, }; var process = Process.Start(startInfo); @@ -106,8 +101,6 @@ namespace Microsoft.Extensions.Internal { process.Kill(); } - - return process.ExitCode; } } } diff --git a/src/Shared/PropertyHelper/PropertyHelper.cs b/src/Shared/PropertyHelper/PropertyHelper.cs index 27ba5661a4..f6aad151e5 100644 --- a/src/Shared/PropertyHelper/PropertyHelper.cs +++ b/src/Shared/PropertyHelper/PropertyHelper.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; namespace Microsoft.Extensions.Internal { @@ -37,6 +38,12 @@ namespace Microsoft.Extensions.Internal private static readonly ConcurrentDictionary VisiblePropertiesCache = new ConcurrentDictionary(); + // We need to be able to check if a type is a 'ref struct' - but we need to be able to compile + // for platforms where the attribute is not defined, like net46. So we can fetch the attribute + // by late binding. If the attribute isn't defined, then we assume we won't encounter any + // 'ref struct' types. + private static readonly Type IsByRefLikeAttribute = Type.GetType("System.Runtime.CompilerServices.IsByRefLikeAttribute", throwOnError: false); + private Action _valueSetter; private Func _valueGetter; @@ -511,16 +518,34 @@ namespace Microsoft.Extensions.Internal return helpers; } - // Indexed properties are not useful (or valid) for grabbing properties off an object. + private static bool IsInterestingProperty(PropertyInfo property) { // For improving application startup time, do not use GetIndexParameters() api early in this check as it // creates a copy of parameter array and also we would like to check for the presence of a get method // and short circuit asap. - return property.GetMethod != null && + return + property.GetMethod != null && property.GetMethod.IsPublic && !property.GetMethod.IsStatic && + + // PropertyHelper can't work with ref structs. + !IsRefStructProperty(property) && + + // Indexed properties are not useful (or valid) for grabbing properties off an object. property.GetMethod.GetParameters().Length == 0; } + + // PropertyHelper can't really interact with ref-struct properties since they can't be + // boxed and can't be used as generic types. We just ignore them. + // + // see: https://github.com/aspnet/Mvc/issues/8545 + private static bool IsRefStructProperty(PropertyInfo property) + { + return + IsByRefLikeAttribute != null && + property.PropertyType.IsValueType && + property.PropertyType.IsDefined(IsByRefLikeAttribute); + } } } diff --git a/src/Shared/test/Shared.Tests/CertificateManagerTests.cs b/src/Shared/test/Shared.Tests/CertificateManagerTests.cs index 613f5c966f..cd314383c9 100644 --- a/src/Shared/test/Shared.Tests/CertificateManagerTests.cs +++ b/src/Shared/test/Shared.Tests/CertificateManagerTests.cs @@ -1,7 +1,7 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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. -#if NETCOREAPP2_0 || NETCOREAPP2_1 +#if NETCOREAPP2_2 using System; using System.IO; @@ -26,7 +26,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests public ITestOutputHelper Output { get; } - [Fact] + [Fact(Skip = "True")] public void EnsureCreateHttpsCertificate_CreatesACertificate_WhenThereAreNoHttpsCertificates() { try @@ -109,6 +109,92 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests } } + [Fact] + public void EnsureCreateHttpsCertificate2_CreatesACertificate_WhenThereAreNoHttpsCertificates() + { + try + { + // Arrange + const string CertificateName = nameof(EnsureCreateHttpsCertificate_CreatesACertificate_WhenThereAreNoHttpsCertificates) + ".cer"; + var manager = new CertificateManager(); + manager.RemoveAllCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, TestCertificateSubject); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + manager.RemoveAllCertificates(CertificatePurpose.HTTPS, StoreName.Root, StoreLocation.CurrentUser, TestCertificateSubject); + } + + // Act + DateTimeOffset now = DateTimeOffset.UtcNow; + now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); + var result = manager.EnsureAspNetCoreHttpsDevelopmentCertificate2(now, now.AddYears(1), CertificateName, trust: false, subject: TestCertificateSubject); + + // Assert + Assert.Equal(EnsureCertificateResult.Succeeded, result.ResultCode); + Assert.NotNull(result.Diagnostics); + Assert.NotEmpty(result.Diagnostics.Messages); + Assert.Empty(result.Diagnostics.Exceptions); + + Assert.True(File.Exists(CertificateName)); + + var exportedCertificate = new X509Certificate2(File.ReadAllBytes(CertificateName)); + Assert.NotNull(exportedCertificate); + Assert.False(exportedCertificate.HasPrivateKey); + + var httpsCertificates = manager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: false); + var httpsCertificate = Assert.Single(httpsCertificates, c => c.Subject == TestCertificateSubject); + Assert.True(httpsCertificate.HasPrivateKey); + Assert.Equal(TestCertificateSubject, httpsCertificate.Subject); + Assert.Equal(TestCertificateSubject, httpsCertificate.Issuer); + Assert.Equal("sha256RSA", httpsCertificate.SignatureAlgorithm.FriendlyName); + Assert.Equal("1.2.840.113549.1.1.11", httpsCertificate.SignatureAlgorithm.Value); + + Assert.Equal(now.LocalDateTime, httpsCertificate.NotBefore); + Assert.Equal(now.AddYears(1).LocalDateTime, httpsCertificate.NotAfter); + Assert.Contains( + httpsCertificate.Extensions.OfType(), + e => e is X509BasicConstraintsExtension basicConstraints && + basicConstraints.Critical == true && + basicConstraints.CertificateAuthority == false && + basicConstraints.HasPathLengthConstraint == false && + basicConstraints.PathLengthConstraint == 0); + + Assert.Contains( + httpsCertificate.Extensions.OfType(), + e => e is X509KeyUsageExtension keyUsage && + keyUsage.Critical == true && + keyUsage.KeyUsages == X509KeyUsageFlags.KeyEncipherment); + + Assert.Contains( + httpsCertificate.Extensions.OfType(), + e => e is X509EnhancedKeyUsageExtension enhancedKeyUsage && + enhancedKeyUsage.Critical == true && + enhancedKeyUsage.EnhancedKeyUsages.OfType().Single() is Oid keyUsage && + keyUsage.Value == "1.3.6.1.5.5.7.3.1"); + + // Subject alternative name + Assert.Contains( + httpsCertificate.Extensions.OfType(), + e => e.Critical == true && + e.Oid.Value == "2.5.29.17"); + + // ASP.NET HTTPS Development certificate extension + Assert.Contains( + httpsCertificate.Extensions.OfType(), + e => e.Critical == false && + e.Oid.Value == "1.3.6.1.4.1.311.84.1.1" && + Encoding.ASCII.GetString(e.RawData) == "ASP.NET Core HTTPS development certificate"); + + Assert.Equal(httpsCertificate.GetCertHashString(), exportedCertificate.GetCertHashString()); + + } + catch (Exception e) + { + Output.WriteLine(e.Message); + ListCertificates(Output); + throw; + } + } + private void ListCertificates(ITestOutputHelper output) { using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) @@ -125,7 +211,7 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests } } - [Fact] + [Fact(Skip = "true")] public void EnsureCreateHttpsCertificate_DoesNotCreateACertificate_WhenThereIsAnExistingHttpsCertificates() { // Arrange @@ -196,108 +282,6 @@ namespace Microsoft.AspNetCore.Certificates.Generation.Tests Assert.Empty(manager.ListCertificates(CertificatePurpose.HTTPS, StoreName.Root, StoreLocation.CurrentUser, isValid: false).Where(c => c.Subject == TestCertificateSubject)); } } - - [Fact] - public void EnsureCreateIdentityTokenSigningCertificate_CreatesACertificate_WhenThereAreNoHttpsCertificates() - { - // Arrange - const string CertificateName = nameof(EnsureCreateIdentityTokenSigningCertificate_CreatesACertificate_WhenThereAreNoHttpsCertificates) + ".cer"; - var manager = new CertificateManager(); - - manager.RemoveAllCertificates(CertificatePurpose.Signing, StoreName.My, StoreLocation.CurrentUser, TestCertificateSubject); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - manager.RemoveAllCertificates(CertificatePurpose.Signing, StoreName.Root, StoreLocation.CurrentUser, TestCertificateSubject); - } - - // Act - DateTimeOffset now = DateTimeOffset.UtcNow; - now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); - var result = manager.EnsureAspNetCoreApplicationTokensDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, subject: TestCertificateSubject); - - // Assert - Assert.Equal(EnsureCertificateResult.Succeeded, result); - Assert.True(File.Exists(CertificateName)); - - var exportedCertificate = new X509Certificate2(File.ReadAllBytes(CertificateName)); - Assert.NotNull(exportedCertificate); - Assert.False(exportedCertificate.HasPrivateKey); - - var identityCertificates = manager.ListCertificates(CertificatePurpose.Signing, StoreName.My, StoreLocation.CurrentUser, isValid: false); - var identityCertificate = Assert.Single(identityCertificates, i => i.Subject == TestCertificateSubject); - Assert.True(identityCertificate.HasPrivateKey); - Assert.Equal(TestCertificateSubject, identityCertificate.Subject); - Assert.Equal(TestCertificateSubject, identityCertificate.Issuer); - Assert.Equal("sha256RSA", identityCertificate.SignatureAlgorithm.FriendlyName); - Assert.Equal("1.2.840.113549.1.1.11", identityCertificate.SignatureAlgorithm.Value); - - Assert.Equal(now.LocalDateTime, identityCertificate.NotBefore); - Assert.Equal(now.AddYears(1).LocalDateTime, identityCertificate.NotAfter); - Assert.Contains( - identityCertificate.Extensions.OfType(), - e => e is X509BasicConstraintsExtension basicConstraints && - basicConstraints.Critical == true && - basicConstraints.CertificateAuthority == false && - basicConstraints.HasPathLengthConstraint == false && - basicConstraints.PathLengthConstraint == 0); - - Assert.Contains( - identityCertificate.Extensions.OfType(), - e => e is X509KeyUsageExtension keyUsage && - keyUsage.Critical == true && - keyUsage.KeyUsages == X509KeyUsageFlags.DigitalSignature); - - Assert.Contains( - identityCertificate.Extensions.OfType(), - e => e is X509EnhancedKeyUsageExtension enhancedKeyUsage && - enhancedKeyUsage.Critical == true && - enhancedKeyUsage.EnhancedKeyUsages.OfType().Single() is Oid keyUsage && - keyUsage.Value == "1.3.6.1.5.5.7.3.1"); - - // ASP.NET Core Identity Json Web Token signing development certificate - Assert.Contains( - identityCertificate.Extensions.OfType(), - e => e.Critical == false && - e.Oid.Value == "1.3.6.1.4.1.311.84.1.2" && - Encoding.ASCII.GetString(e.RawData) == "ASP.NET Core Identity Json Web Token signing development certificate"); - - Assert.Equal(identityCertificate.GetCertHashString(), exportedCertificate.GetCertHashString()); - } - - [Fact] - public void EnsureCreateIdentityTokenSigningCertificate_DoesNotCreateACertificate_WhenThereIsAnExistingHttpsCertificates() - { - // Arrange - const string CertificateName = nameof(EnsureCreateIdentityTokenSigningCertificate_DoesNotCreateACertificate_WhenThereIsAnExistingHttpsCertificates) + ".pfx"; - var certificatePassword = Guid.NewGuid().ToString(); - - var manager = new CertificateManager(); - - manager.RemoveAllCertificates(CertificatePurpose.Signing, StoreName.My, StoreLocation.CurrentUser, TestCertificateSubject); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - manager.RemoveAllCertificates(CertificatePurpose.Signing, StoreName.Root, StoreLocation.CurrentUser, TestCertificateSubject); - } - - DateTimeOffset now = DateTimeOffset.UtcNow; - now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset); - manager.EnsureAspNetCoreApplicationTokensDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, subject: TestCertificateSubject); - - var identityTokenSigningCertificates = manager.ListCertificates(CertificatePurpose.Signing, StoreName.My, StoreLocation.CurrentUser, isValid: false).Single(c => c.Subject == TestCertificateSubject); - - // Act - var result = manager.EnsureAspNetCoreApplicationTokensDevelopmentCertificate(now, now.AddYears(1), CertificateName, trust: false, includePrivateKey: true, password: certificatePassword, subject: TestCertificateSubject); - - // Assert - Assert.Equal(EnsureCertificateResult.ValidCertificatePresent, result); - Assert.True(File.Exists(CertificateName)); - - var exportedCertificate = new X509Certificate2(File.ReadAllBytes(CertificateName), certificatePassword); - Assert.NotNull(exportedCertificate); - Assert.True(exportedCertificate.HasPrivateKey); - - Assert.Equal(identityTokenSigningCertificates.GetCertHashString(), exportedCertificate.GetCertHashString()); - } } } diff --git a/src/Shared/test/Shared.Tests/DotNetMuxerTests.cs b/src/Shared/test/Shared.Tests/DotNetMuxerTests.cs index ba1dd06511..92e06a8f70 100644 --- a/src/Shared/test/Shared.Tests/DotNetMuxerTests.cs +++ b/src/Shared/test/Shared.Tests/DotNetMuxerTests.cs @@ -1,7 +1,7 @@ // 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. -#if NETCOREAPP2_0 || NETCOREAPP2_1 +#if NETCOREAPP2_2 using System.IO; using System.Runtime.InteropServices; using Xunit; diff --git a/src/Shared/test/Shared.Tests/NonCapturingTimerTest.cs b/src/Shared/test/Shared.Tests/NonCapturingTimerTest.cs new file mode 100644 index 0000000000..ef21ce5f3b --- /dev/null +++ b/src/Shared/test/Shared.Tests/NonCapturingTimerTest.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; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.Internal +{ + public class NonCapturingTimerTest + { + [Fact] + public async Task NonCapturingTimer_DoesntCaptureExecutionContext() + { + // Arrange + var message = new AsyncLocal(); + message.Value = "Hey, this is a value stored in the execuion context"; + + var tcs = new TaskCompletionSource(); + + // Act + var timer = NonCapturingTimer.Create((_) => + { + // Observe the value based on the current execution context + tcs.SetResult(message.Value); + }, state: null, dueTime: TimeSpan.FromMilliseconds(1), Timeout.InfiniteTimeSpan); + + // Assert + var messageFromTimer = await tcs.Task; + timer.Dispose(); + + // ExecutionContext didn't flow to timer callback + Assert.Null(messageFromTimer); + + // ExecutionContext was restored + Assert.NotNull(await Task.Run(() => message.Value)); + } + } +} diff --git a/src/Shared/test/Shared.Tests/PropertyHelperTest.cs b/src/Shared/test/Shared.Tests/PropertyHelperTest.cs index 19cf08b370..1c43dc880b 100644 --- a/src/Shared/test/Shared.Tests/PropertyHelperTest.cs +++ b/src/Shared/test/Shared.Tests/PropertyHelperTest.cs @@ -153,6 +153,22 @@ namespace Microsoft.Extensions.Internal Assert.Equal("Prop5", helper.Name); } +#if NETSTANDARD || NETCOREAPP + [Fact] + public void PropertyHelper_RefStructProperties() + { + // Arrange + var obj = new RefStructProperties(); + + // Act + Assert + var helper = Assert.Single(PropertyHelper.GetProperties(obj.GetType().GetTypeInfo())); + Assert.Equal("Prop5", helper.Name); + } +#elif NET46 || NET461 +#else +#error Unknown TFM - update the set of TFMs where we test for ref structs +#endif + [Fact] public void PropertyHelper_DoesNotFindSetOnlyProperties() { @@ -718,6 +734,22 @@ namespace Microsoft.Extensions.Internal public int Prop5 { get; set; } } +#if NETSTANDARD || NETCOREAPP + private class RefStructProperties + { + public Span Span => throw new NotImplementedException(); + public MyRefStruct UserDefined => throw new NotImplementedException(); + + public int Prop5 { get; set; } + } + + private readonly ref struct MyRefStruct + { + } +#elif NET46 || NET461 +#else +#error Unknown TFM - update the set of TFMs where we test for ref structs +#endif private struct MyProperties { public int IntProp { get; set; } diff --git a/src/Shared/test/testassets/ThrowingLibrary/ThrowingLibrary.csproj b/src/Shared/test/testassets/ThrowingLibrary/ThrowingLibrary.csproj index d77d392873..2b2900911a 100644 --- a/src/Shared/test/testassets/ThrowingLibrary/ThrowingLibrary.csproj +++ b/src/Shared/test/testassets/ThrowingLibrary/ThrowingLibrary.csproj @@ -3,6 +3,7 @@ netstandard2.0 portable + false diff --git a/src/Testing/src/CultureReplacer.cs b/src/Testing/src/CultureReplacer.cs new file mode 100644 index 0000000000..51e35e8354 --- /dev/null +++ b/src/Testing/src/CultureReplacer.cs @@ -0,0 +1,79 @@ +// 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.Globalization; +using System.Threading; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class CultureReplacer : IDisposable + { + private const string _defaultCultureName = "en-GB"; + private const string _defaultUICultureName = "en-US"; + private static readonly CultureInfo _defaultCulture = new CultureInfo(_defaultCultureName); + private readonly CultureInfo _originalCulture; + private readonly CultureInfo _originalUICulture; + private readonly long _threadId; + + // Culture => Formatting of dates/times/money/etc, defaults to en-GB because en-US is the same as InvariantCulture + // We want to be able to find issues where the InvariantCulture is used, but a specific culture should be. + // + // UICulture => Language + public CultureReplacer(string culture = _defaultCultureName, string uiCulture = _defaultUICultureName) + : this(new CultureInfo(culture), new CultureInfo(uiCulture)) + { + } + + public CultureReplacer(CultureInfo culture, CultureInfo uiCulture) + { + _originalCulture = CultureInfo.CurrentCulture; + _originalUICulture = CultureInfo.CurrentUICulture; + _threadId = Thread.CurrentThread.ManagedThreadId; + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = uiCulture; + } + + /// + /// The name of the culture that is used as the default value for CultureInfo.DefaultThreadCurrentCulture when CultureReplacer is used. + /// + public static string DefaultCultureName + { + get { return _defaultCultureName; } + } + + /// + /// The name of the culture that is used as the default value for [Thread.CurrentThread(NET45)/CultureInfo(K10)].CurrentUICulture when CultureReplacer is used. + /// + public static string DefaultUICultureName + { + get { return _defaultUICultureName; } + } + + /// + /// The culture that is used as the default value for [Thread.CurrentThread(NET45)/CultureInfo(K10)].CurrentCulture when CultureReplacer is used. + /// + public static CultureInfo DefaultCulture + { + get { return _defaultCulture; } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (disposing) + { + Assert.True(Thread.CurrentThread.ManagedThreadId == _threadId, + "The current thread is not the same as the thread invoking the constructor. This should never happen."); + CultureInfo.CurrentCulture = _originalCulture; + CultureInfo.CurrentUICulture = _originalUICulture; + } + } + } +} diff --git a/src/Testing/src/ExceptionAssertions.cs b/src/Testing/src/ExceptionAssertions.cs new file mode 100644 index 0000000000..244cad5a37 --- /dev/null +++ b/src/Testing/src/ExceptionAssertions.cs @@ -0,0 +1,271 @@ +// 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.Reflection; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + // TODO: eventually want: public partial class Assert : Xunit.Assert + public static class ExceptionAssert + { + /// + /// Verifies that an exception of the given type (or optionally a derived type) is thrown. + /// + /// The type of the exception expected to be thrown + /// A delegate to the code to be tested + /// The exception that was thrown, when successful + public static TException Throws(Action testCode) + where TException : Exception + { + return VerifyException(RecordException(testCode)); + } + + /// + /// Verifies that an exception of the given type is thrown. + /// Also verifies that the exception message matches. + /// + /// The type of the exception expected to be thrown + /// A delegate to the code to be tested + /// The exception message to verify + /// The exception that was thrown, when successful + public static TException Throws(Action testCode, string exceptionMessage) + where TException : Exception + { + var ex = Throws(testCode); + VerifyExceptionMessage(ex, exceptionMessage); + return ex; + } + + /// + /// Verifies that an exception of the given type is thrown. + /// Also verifies that the exception message matches. + /// + /// The type of the exception expected to be thrown + /// A delegate to the code to be tested + /// The exception message to verify + /// The exception that was thrown, when successful + public static async Task ThrowsAsync(Func testCode, string exceptionMessage) + where TException : Exception + { + // The 'testCode' Task might execute asynchronously in a different thread making it hard to enforce the thread culture. + // The correct way to verify exception messages in such a scenario would be to run the task synchronously inside of a + // culture enforced block. + var ex = await Assert.ThrowsAsync(testCode); + VerifyExceptionMessage(ex, exceptionMessage); + return ex; + } + + /// + /// Verifies that an exception of the given type is thrown. + /// Also verified that the exception message matches. + /// + /// The type of the exception expected to be thrown + /// A delegate to the code to be tested + /// The exception message to verify + /// The exception that was thrown, when successful + public static TException Throws(Func testCode, string exceptionMessage) + where TException : Exception + { + return Throws(() => { testCode(); }, exceptionMessage); + } + + /// + /// Verifies that the code throws an . + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception message to verify + /// The exception that was thrown, when successful + public static ArgumentException ThrowsArgument(Action testCode, string paramName, string exceptionMessage) + { + return ThrowsArgumentInternal(testCode, paramName, exceptionMessage); + } + + private static TException ThrowsArgumentInternal( + Action testCode, + string paramName, + string exceptionMessage) + where TException : ArgumentException + { + var ex = Throws(testCode); + if (paramName != null) + { + Assert.Equal(paramName, ex.ParamName); + } + VerifyExceptionMessage(ex, exceptionMessage, partialMatch: true); + return ex; + } + + /// + /// Verifies that the code throws an . + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception message to verify + /// The exception that was thrown, when successful + public static Task ThrowsArgumentAsync(Func testCode, string paramName, string exceptionMessage) + { + return ThrowsArgumentAsyncInternal(testCode, paramName, exceptionMessage); + } + + private static async Task ThrowsArgumentAsyncInternal( + Func testCode, + string paramName, + string exceptionMessage) + where TException : ArgumentException + { + var ex = await Assert.ThrowsAsync(testCode); + if (paramName != null) + { + Assert.Equal(paramName, ex.ParamName); + } + VerifyExceptionMessage(ex, exceptionMessage, partialMatch: true); + return ex; + } + + /// + /// Verifies that the code throws an . + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception that was thrown, when successful + public static ArgumentNullException ThrowsArgumentNull(Action testCode, string paramName) + { + var ex = Throws(testCode); + if (paramName != null) + { + Assert.Equal(paramName, ex.ParamName); + } + return ex; + } + + /// + /// Verifies that the code throws an ArgumentException with the expected message that indicates that the value cannot + /// be null or empty. + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception that was thrown, when successful + public static ArgumentException ThrowsArgumentNullOrEmpty(Action testCode, string paramName) + { + return ThrowsArgumentInternal(testCode, paramName, "Value cannot be null or empty."); + } + + /// + /// Verifies that the code throws an ArgumentException with the expected message that indicates that the value cannot + /// be null or empty. + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception that was thrown, when successful + public static Task ThrowsArgumentNullOrEmptyAsync(Func testCode, string paramName) + { + return ThrowsArgumentAsyncInternal(testCode, paramName, "Value cannot be null or empty."); + } + + /// + /// Verifies that the code throws an ArgumentNullException with the expected message that indicates that the value cannot + /// be null or empty string. + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception that was thrown, when successful + public static ArgumentException ThrowsArgumentNullOrEmptyString(Action testCode, string paramName) + { + return ThrowsArgumentInternal(testCode, paramName, "Value cannot be null or an empty string."); + } + + /// + /// Verifies that the code throws an ArgumentNullException with the expected message that indicates that the value cannot + /// be null or empty string. + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception that was thrown, when successful + public static Task ThrowsArgumentNullOrEmptyStringAsync(Func testCode, string paramName) + { + return ThrowsArgumentAsyncInternal(testCode, paramName, "Value cannot be null or an empty string."); + } + + /// + /// Verifies that the code throws an ArgumentOutOfRangeException (or optionally any exception which derives from it). + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception message to verify + /// The actual value provided + /// The exception that was thrown, when successful + public static ArgumentOutOfRangeException ThrowsArgumentOutOfRange(Action testCode, string paramName, string exceptionMessage, object actualValue = null) + { + var ex = ThrowsArgumentInternal(testCode, paramName, exceptionMessage); + + if (paramName != null) + { + Assert.Equal(paramName, ex.ParamName); + } + + if (actualValue != null) + { + Assert.Equal(actualValue, ex.ActualValue); + } + + return ex; + } + + // We've re-implemented all the xUnit.net Throws code so that we can get this + // updated implementation of RecordException which silently unwraps any instances + // of AggregateException. In addition to unwrapping exceptions, this method ensures + // that tests are executed in with a known set of Culture and UICulture. This prevents + // tests from failing when executed on a non-English machine. + private static Exception RecordException(Action testCode) + { + try + { + using (new CultureReplacer()) + { + testCode(); + } + return null; + } + catch (Exception exception) + { + return UnwrapException(exception); + } + } + + private static Exception UnwrapException(Exception exception) + { + var aggEx = exception as AggregateException; + return aggEx != null ? aggEx.GetBaseException() : exception; + } + + private static TException VerifyException(Exception exception) + { + var tie = exception as TargetInvocationException; + if (tie != null) + { + exception = tie.InnerException; + } + Assert.NotNull(exception); + return Assert.IsAssignableFrom(exception); + } + + private static void VerifyExceptionMessage(Exception exception, string expectedMessage, bool partialMatch = false) + { + if (expectedMessage != null) + { + if (!partialMatch) + { + Assert.Equal(expectedMessage, exception.Message); + } + else + { + Assert.Contains(expectedMessage, exception.Message); + } + } + } + } +} \ No newline at end of file diff --git a/src/Testing/src/HttpClientSlim.cs b/src/Testing/src/HttpClientSlim.cs new file mode 100644 index 0000000000..6214ffefc1 --- /dev/null +++ b/src/Testing/src/HttpClientSlim.cs @@ -0,0 +1,158 @@ +// 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.Globalization; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Testing +{ + /// + /// Lightweight version of HttpClient implemented using Socket and SslStream. + /// + public static class HttpClientSlim + { + public static async Task GetStringAsync(string requestUri, bool validateCertificate = true) + => await GetStringAsync(new Uri(requestUri), validateCertificate).ConfigureAwait(false); + + public static async Task GetStringAsync(Uri requestUri, bool validateCertificate = true) + { + using (var stream = await GetStream(requestUri, validateCertificate).ConfigureAwait(false)) + { + using (var writer = new StreamWriter(stream, Encoding.ASCII, bufferSize: 1024, leaveOpen: true)) + { + await writer.WriteAsync($"GET {requestUri.PathAndQuery} HTTP/1.0\r\n").ConfigureAwait(false); + await writer.WriteAsync($"Host: {GetHost(requestUri)}\r\n").ConfigureAwait(false); + await writer.WriteAsync("\r\n").ConfigureAwait(false); + } + + return await ReadResponse(stream).ConfigureAwait(false); + } + } + + internal static string GetHost(Uri requestUri) + { + var authority = requestUri.Authority; + if (requestUri.HostNameType == UriHostNameType.IPv6) + { + // Make sure there's no % scope id. https://github.com/aspnet/KestrelHttpServer/issues/2637 + var address = IPAddress.Parse(requestUri.Host); + address = new IPAddress(address.GetAddressBytes()); // Drop scope Id. + if (requestUri.IsDefaultPort) + { + authority = $"[{address}]"; + } + else + { + authority = $"[{address}]:{requestUri.Port.ToString(CultureInfo.InvariantCulture)}"; + } + } + return authority; + } + + public static async Task PostAsync(string requestUri, HttpContent content, bool validateCertificate = true) + => await PostAsync(new Uri(requestUri), content, validateCertificate).ConfigureAwait(false); + + public static async Task PostAsync(Uri requestUri, HttpContent content, bool validateCertificate = true) + { + using (var stream = await GetStream(requestUri, validateCertificate)) + { + using (var writer = new StreamWriter(stream, Encoding.ASCII, bufferSize: 1024, leaveOpen: true)) + { + await writer.WriteAsync($"POST {requestUri.PathAndQuery} HTTP/1.0\r\n").ConfigureAwait(false); + await writer.WriteAsync($"Host: {requestUri.Authority}\r\n").ConfigureAwait(false); + await writer.WriteAsync($"Content-Type: {content.Headers.ContentType}\r\n").ConfigureAwait(false); + await writer.WriteAsync($"Content-Length: {content.Headers.ContentLength}\r\n").ConfigureAwait(false); + await writer.WriteAsync("\r\n").ConfigureAwait(false); + } + + await content.CopyToAsync(stream).ConfigureAwait(false); + + return await ReadResponse(stream).ConfigureAwait(false); + } + } + + private static async Task ReadResponse(Stream stream) + { + using (var reader = new StreamReader(stream, Encoding.ASCII, detectEncodingFromByteOrderMarks: true, + bufferSize: 1024, leaveOpen: true)) + { + var response = await reader.ReadToEndAsync().ConfigureAwait(false); + + var status = GetStatus(response); + new HttpResponseMessage(status).EnsureSuccessStatusCode(); + + var body = response.Substring(response.IndexOf("\r\n\r\n") + 4); + return body; + } + } + + private static HttpStatusCode GetStatus(string response) + { + var statusStart = response.IndexOf(' ') + 1; + var statusEnd = response.IndexOf(' ', statusStart) - 1; + var statusLength = statusEnd - statusStart + 1; + + if (statusLength < 1) + { + throw new InvalidDataException($"No StatusCode found in '{response}'"); + } + + return (HttpStatusCode)int.Parse(response.Substring(statusStart, statusLength)); + } + + private static async Task GetStream(Uri requestUri, bool validateCertificate) + { + var socket = await GetSocket(requestUri); + var stream = new NetworkStream(socket, ownsSocket: true); + + if (requestUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) + { + var sslStream = new SslStream(stream, leaveInnerStreamOpen: false, userCertificateValidationCallback: + validateCertificate ? null : (RemoteCertificateValidationCallback)((a, b, c, d) => true)); + + await sslStream.AuthenticateAsClientAsync(requestUri.Host, clientCertificates: null, + enabledSslProtocols: SslProtocols.Tls11 | SslProtocols.Tls12, + checkCertificateRevocation: validateCertificate).ConfigureAwait(false); + return sslStream; + } + else + { + return stream; + } + } + + public static async Task GetSocket(Uri requestUri) + { + var tcs = new TaskCompletionSource(); + + var socketArgs = new SocketAsyncEventArgs(); + socketArgs.RemoteEndPoint = new DnsEndPoint(requestUri.DnsSafeHost, requestUri.Port); + socketArgs.Completed += (s, e) => tcs.TrySetResult(e.ConnectSocket); + + // Must use static ConnectAsync(), since instance Connect() does not support DNS names on OSX/Linux. + if (Socket.ConnectAsync(SocketType.Stream, ProtocolType.Tcp, socketArgs)) + { + await tcs.Task.ConfigureAwait(false); + } + + var socket = socketArgs.ConnectSocket; + + if (socket == null) + { + throw new SocketException((int)socketArgs.SocketError); + } + else + { + return socket; + } + } + } +} diff --git a/src/Testing/src/Microsoft.AspNetCore.Testing.csproj b/src/Testing/src/Microsoft.AspNetCore.Testing.csproj new file mode 100644 index 0000000000..d9d9008dd2 --- /dev/null +++ b/src/Testing/src/Microsoft.AspNetCore.Testing.csproj @@ -0,0 +1,33 @@ + + + + Various helpers for writing tests that use ASP.NET Core. + netstandard2.0;net46 + $(NoWarn);CS1591 + true + aspnetcore + false + true + + + + + + + + + + + + + + + + + + True + contentFiles\cs\netstandard2.0\ + + + + diff --git a/src/Testing/src/Properties/AssemblyInfo.cs b/src/Testing/src/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..0212e111ee --- /dev/null +++ b/src/Testing/src/Properties/AssemblyInfo.cs @@ -0,0 +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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Testing.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Testing/src/ReplaceCulture.cs b/src/Testing/src/ReplaceCulture.cs new file mode 100644 index 0000000000..9580bfd0da --- /dev/null +++ b/src/Testing/src/ReplaceCulture.cs @@ -0,0 +1,70 @@ +// 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.Globalization; +using System.Reflection; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + /// + /// Replaces the current culture and UI culture for the test. + /// + [AttributeUsage(AttributeTargets.Method)] + public class ReplaceCultureAttribute : BeforeAfterTestAttribute + { + private const string _defaultCultureName = "en-GB"; + private const string _defaultUICultureName = "en-US"; + private CultureInfo _originalCulture; + private CultureInfo _originalUICulture; + + /// + /// Replaces the current culture and UI culture to en-GB and en-US respectively. + /// + public ReplaceCultureAttribute() : + this(_defaultCultureName, _defaultUICultureName) + { + } + + /// + /// Replaces the current culture and UI culture based on specified values. + /// + public ReplaceCultureAttribute(string currentCulture, string currentUICulture) + { + Culture = new CultureInfo(currentCulture); + UICulture = new CultureInfo(currentUICulture); + } + + /// + /// The for the test. Defaults to en-GB. + /// + /// + /// en-GB is used here as the default because en-US is equivalent to the InvariantCulture. We + /// want to be able to find bugs where we're accidentally relying on the Invariant instead of the + /// user's culture. + /// + public CultureInfo Culture { get; } + + /// + /// The for the test. Defaults to en-US. + /// + public CultureInfo UICulture { get; } + + public override void Before(MethodInfo methodUnderTest) + { + _originalCulture = CultureInfo.CurrentCulture; + _originalUICulture = CultureInfo.CurrentUICulture; + + CultureInfo.CurrentCulture = Culture; + CultureInfo.CurrentUICulture = UICulture; + } + + public override void After(MethodInfo methodUnderTest) + { + CultureInfo.CurrentCulture = _originalCulture; + CultureInfo.CurrentUICulture = _originalUICulture; + } + } +} + diff --git a/src/Testing/src/TaskExtensions.cs b/src/Testing/src/TaskExtensions.cs new file mode 100644 index 0000000000..83130aeae4 --- /dev/null +++ b/src/Testing/src/TaskExtensions.cs @@ -0,0 +1,64 @@ +// 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.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Testing +{ + public static class TaskExtensions + { + public static async Task TimeoutAfter(this Task task, TimeSpan timeout, + [CallerFilePath] string filePath = null, + [CallerLineNumber] int lineNumber = default(int)) + { + // Don't create a timer if the task is already completed + if (task.IsCompleted) + { + return await task; + } + + var cts = new CancellationTokenSource(); + if (task == await Task.WhenAny(task, Task.Delay(timeout, cts.Token))) + { + cts.Cancel(); + return await task; + } + else + { + throw new TimeoutException( + CreateMessage(timeout, filePath, lineNumber)); + } + } + + public static async Task TimeoutAfter(this Task task, TimeSpan timeout, + [CallerFilePath] string filePath = null, + [CallerLineNumber] int lineNumber = default(int)) + { + // Don't create a timer if the task is already completed + if (task.IsCompleted) + { + await task; + return; + } + + var cts = new CancellationTokenSource(); + if (task == await Task.WhenAny(task, Task.Delay(timeout, cts.Token))) + { + cts.Cancel(); + await task; + } + else + { + throw new TimeoutException(CreateMessage(timeout, filePath, lineNumber)); + } + } + + private static string CreateMessage(TimeSpan timeout, string filePath, int lineNumber) + => string.IsNullOrEmpty(filePath) + ? $"The operation timed out after reaching the limit of {timeout.TotalMilliseconds}ms." + : $"The operation at {filePath}:{lineNumber} timed out after reaching the limit of {timeout.TotalMilliseconds}ms."; + } +} diff --git a/src/Testing/src/TestPathUtilities.cs b/src/Testing/src/TestPathUtilities.cs new file mode 100644 index 0000000000..ebd10897c3 --- /dev/null +++ b/src/Testing/src/TestPathUtilities.cs @@ -0,0 +1,31 @@ +// 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; + +namespace Microsoft.AspNetCore.Testing +{ + public class TestPathUtilities + { + public static string GetSolutionRootDirectory(string solution) + { + var applicationBasePath = AppContext.BaseDirectory; + var directoryInfo = new DirectoryInfo(applicationBasePath); + + do + { + var projectFileInfo = new FileInfo(Path.Combine(directoryInfo.FullName, $"{solution}.sln")); + if (projectFileInfo.Exists) + { + return projectFileInfo.DirectoryName; + } + + directoryInfo = directoryInfo.Parent; + } + while (directoryInfo.Parent != null); + + throw new Exception($"Solution file {solution}.sln could not be found in {applicationBasePath} or its parent directories."); + } + } +} diff --git a/src/Testing/src/TestPlatformHelper.cs b/src/Testing/src/TestPlatformHelper.cs new file mode 100644 index 0000000000..1a3f275c7e --- /dev/null +++ b/src/Testing/src/TestPlatformHelper.cs @@ -0,0 +1,23 @@ +// 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.Runtime.InteropServices; + +namespace Microsoft.AspNetCore.Testing +{ + public static class TestPlatformHelper + { + public static bool IsMono => + Type.GetType("Mono.Runtime") != null; + + public static bool IsWindows => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + public static bool IsLinux => + RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + public static bool IsMac => + RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + } +} \ No newline at end of file diff --git a/src/Testing/src/Tracing/CollectingEventListener.cs b/src/Testing/src/Tracing/CollectingEventListener.cs new file mode 100644 index 0000000000..d22a4996af --- /dev/null +++ b/src/Testing/src/Tracing/CollectingEventListener.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.Tracing; +using System.Linq; + +namespace Microsoft.AspNetCore.Testing.Tracing +{ + public class CollectingEventListener : EventListener + { + private ConcurrentQueue _events = new ConcurrentQueue(); + + private object _lock = new object(); + + private Dictionary _existingSources = new Dictionary(StringComparer.OrdinalIgnoreCase); + private HashSet _requestedEventSources = new HashSet(); + + public void CollectFrom(string eventSourceName) + { + lock(_lock) + { + // Check if it's already been created + if(_existingSources.TryGetValue(eventSourceName, out var existingSource)) + { + // It has, so just enable it now + CollectFrom(existingSource); + } + else + { + // It hasn't, so queue this request for when it is created + _requestedEventSources.Add(eventSourceName); + } + } + } + + public void CollectFrom(EventSource eventSource) => EnableEvents(eventSource, EventLevel.Verbose, EventKeywords.All); + + public IReadOnlyList GetEventsWritten() => _events.ToArray(); + + protected override void OnEventSourceCreated(EventSource eventSource) + { + lock (_lock) + { + // Add this to the list of existing sources for future CollectEventsFrom requests. + _existingSources[eventSource.Name] = eventSource; + + // Check if we have a pending request to enable it + if (_requestedEventSources.Contains(eventSource.Name)) + { + CollectFrom(eventSource); + } + } + } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + _events.Enqueue(eventData); + } + } +} diff --git a/src/Testing/src/Tracing/EventAssert.cs b/src/Testing/src/Tracing/EventAssert.cs new file mode 100644 index 0000000000..b32fb36dad --- /dev/null +++ b/src/Testing/src/Tracing/EventAssert.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Tracing; +using System.Linq; +using Xunit; + +namespace Microsoft.AspNetCore.Testing.Tracing +{ + public class EventAssert + { + private readonly int _expectedId; + private readonly string _expectedName; + private readonly EventLevel _expectedLevel; + private readonly IList<(string name, Action asserter)> _payloadAsserters = new List<(string, Action)>(); + + public EventAssert(int expectedId, string expectedName, EventLevel expectedLevel) + { + _expectedId = expectedId; + _expectedName = expectedName; + _expectedLevel = expectedLevel; + } + + public static void Collection(IEnumerable events, params EventAssert[] asserts) + { + Assert.Collection( + events, + asserts.Select(a => a.CreateAsserter()).ToArray()); + } + + public static EventAssert Event(int id, string name, EventLevel level) + { + return new EventAssert(id, name, level); + } + + public EventAssert Payload(string name, object expectedValue) => Payload(name, actualValue => Assert.Equal(expectedValue, actualValue)); + + public EventAssert Payload(string name, Action asserter) + { + _payloadAsserters.Add((name, asserter)); + return this; + } + + private Action CreateAsserter() => Execute; + + private void Execute(EventWrittenEventArgs evt) + { + Assert.Equal(_expectedId, evt.EventId); + Assert.Equal(_expectedName, evt.EventName); + Assert.Equal(_expectedLevel, evt.Level); + + Action CreateNameAsserter((string name, Action asserter) val) + { + return actualValue => Assert.Equal(val.name, actualValue); + } + + Assert.Collection(evt.PayloadNames, _payloadAsserters.Select(CreateNameAsserter).ToArray()); + Assert.Collection(evt.Payload, _payloadAsserters.Select(t => t.asserter).ToArray()); + } + } +} diff --git a/src/Testing/src/Tracing/EventSourceTestBase.cs b/src/Testing/src/Tracing/EventSourceTestBase.cs new file mode 100644 index 0000000000..721966d6c5 --- /dev/null +++ b/src/Testing/src/Tracing/EventSourceTestBase.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Tracing; +using Xunit; + +namespace Microsoft.AspNetCore.Testing.Tracing +{ + // This collection attribute is what makes the "magic" happen. It forces xunit to run all tests that inherit from this + // base class sequentially, preventing conflicts (since EventSource/EventListener is a process-global concept). + [Collection(CollectionName)] + public abstract class EventSourceTestBase : IDisposable + { + public const string CollectionName = "Microsoft.AspNetCore.Testing.Tracing.EventSourceTestCollection"; + + private readonly CollectingEventListener _listener; + + public EventSourceTestBase() + { + _listener = new CollectingEventListener(); + } + + protected void CollectFrom(string eventSourceName) + { + _listener.CollectFrom(eventSourceName); + } + + protected void CollectFrom(EventSource eventSource) + { + _listener.CollectFrom(eventSource); + } + + protected IReadOnlyList GetEvents() => _listener.GetEventsWritten(); + + public void Dispose() + { + _listener.Dispose(); + } + } +} diff --git a/src/Testing/src/contentFiles/cs/netstandard2.0/EventSourceTestCollection.cs b/src/Testing/src/contentFiles/cs/netstandard2.0/EventSourceTestCollection.cs new file mode 100644 index 0000000000..0ed9e1a9a9 --- /dev/null +++ b/src/Testing/src/contentFiles/cs/netstandard2.0/EventSourceTestCollection.cs @@ -0,0 +1,10 @@ +namespace Microsoft.AspNetCore.Testing.Tracing +{ + // This file comes from Microsoft.AspNetCore.Testing and has to be defined in the test assembly. + // It enables EventSourceTestBase's parallel isolation functionality. + + [Xunit.CollectionDefinition(EventSourceTestBase.CollectionName, DisableParallelization = true)] + public class EventSourceTestCollection + { + } +} diff --git a/src/Testing/src/xunit/ConditionalFactAttribute.cs b/src/Testing/src/xunit/ConditionalFactAttribute.cs new file mode 100644 index 0000000000..7448b48d8c --- /dev/null +++ b/src/Testing/src/xunit/ConditionalFactAttribute.cs @@ -0,0 +1,15 @@ +// 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 Xunit; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing.xunit +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + [XunitTestCaseDiscoverer("Microsoft.AspNetCore.Testing.xunit." + nameof(ConditionalFactDiscoverer), "Microsoft.AspNetCore.Testing")] + public class ConditionalFactAttribute : FactAttribute + { + } +} \ No newline at end of file diff --git a/src/Testing/src/xunit/ConditionalFactDiscoverer.cs b/src/Testing/src/xunit/ConditionalFactDiscoverer.cs new file mode 100644 index 0000000000..819373fa31 --- /dev/null +++ b/src/Testing/src/xunit/ConditionalFactDiscoverer.cs @@ -0,0 +1,27 @@ +// 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 Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing.xunit +{ + internal class ConditionalFactDiscoverer : FactDiscoverer + { + private readonly IMessageSink _diagnosticMessageSink; + + public ConditionalFactDiscoverer(IMessageSink diagnosticMessageSink) + : base(diagnosticMessageSink) + { + _diagnosticMessageSink = diagnosticMessageSink; + } + + protected override IXunitTestCase CreateTestCase(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute) + { + var skipReason = testMethod.EvaluateSkipConditions(); + return skipReason != null + ? new SkippedTestCase(skipReason, _diagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), testMethod) + : base.CreateTestCase(discoveryOptions, testMethod, factAttribute); + } + } +} \ No newline at end of file diff --git a/src/Testing/src/xunit/ConditionalTheoryAttribute.cs b/src/Testing/src/xunit/ConditionalTheoryAttribute.cs new file mode 100644 index 0000000000..9249078cc5 --- /dev/null +++ b/src/Testing/src/xunit/ConditionalTheoryAttribute.cs @@ -0,0 +1,15 @@ +// 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 Xunit; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing.xunit +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + [XunitTestCaseDiscoverer("Microsoft.AspNetCore.Testing.xunit." + nameof(ConditionalTheoryDiscoverer), "Microsoft.AspNetCore.Testing")] + public class ConditionalTheoryAttribute : TheoryAttribute + { + } +} \ No newline at end of file diff --git a/src/Testing/src/xunit/ConditionalTheoryDiscoverer.cs b/src/Testing/src/xunit/ConditionalTheoryDiscoverer.cs new file mode 100644 index 0000000000..d24421f5cd --- /dev/null +++ b/src/Testing/src/xunit/ConditionalTheoryDiscoverer.cs @@ -0,0 +1,47 @@ +// 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.Collections.Generic; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing.xunit +{ + internal class ConditionalTheoryDiscoverer : TheoryDiscoverer + { + public ConditionalTheoryDiscoverer(IMessageSink diagnosticMessageSink) + : base(diagnosticMessageSink) + { + } + + protected override IEnumerable CreateTestCasesForTheory(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute) + { + var skipReason = testMethod.EvaluateSkipConditions(); + return skipReason != null + ? new[] { new SkippedTestCase(skipReason, DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), testMethod) } + : base.CreateTestCasesForTheory(discoveryOptions, testMethod, theoryAttribute); + } + + protected override IEnumerable CreateTestCasesForDataRow(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute, object[] dataRow) + { + var skipReason = testMethod.EvaluateSkipConditions(); + if (skipReason == null && dataRow?.Length > 0) + { + var obj = dataRow[0]; + if (obj != null) + { + var type = obj.GetType(); + var property = type.GetProperty("Skip"); + if (property != null && property.PropertyType.Equals(typeof(string))) + { + skipReason = property.GetValue(obj) as string; + } + } + } + + return skipReason != null ? + base.CreateTestCasesForSkippedDataRow(discoveryOptions, testMethod, theoryAttribute, dataRow, skipReason) + : base.CreateTestCasesForDataRow(discoveryOptions, testMethod, theoryAttribute, dataRow); + } + } +} \ No newline at end of file diff --git a/src/Testing/src/xunit/DockerOnlyAttribute.cs b/src/Testing/src/xunit/DockerOnlyAttribute.cs new file mode 100644 index 0000000000..d67a35a672 --- /dev/null +++ b/src/Testing/src/xunit/DockerOnlyAttribute.cs @@ -0,0 +1,38 @@ +// 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; + +namespace Microsoft.AspNetCore.Testing.xunit +{ + [AttributeUsage(AttributeTargets.Method, Inherited = true, AllowMultiple = false)] + public sealed class DockerOnlyAttribute : Attribute, ITestCondition + { + public string SkipReason { get; } = "This test can only run in a Docker container."; + + public bool IsMet + { + get + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // we currently don't have a good way to detect if running in a Windows container + return false; + } + + const string procFile = "/proc/1/cgroup"; + if (!File.Exists(procFile)) + { + return false; + } + + var lines = File.ReadAllLines(procFile); + // typically the last line in the file is "1:name=openrc:/docker" + return lines.Reverse().Any(l => l.EndsWith("name=openrc:/docker", StringComparison.Ordinal)); + } + } + } +} diff --git a/src/Testing/src/xunit/EnvironmentVariableSkipConditionAttribute.cs b/src/Testing/src/xunit/EnvironmentVariableSkipConditionAttribute.cs new file mode 100644 index 0000000000..8bf1bfd15e --- /dev/null +++ b/src/Testing/src/xunit/EnvironmentVariableSkipConditionAttribute.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.Linq; + +namespace Microsoft.AspNetCore.Testing.xunit +{ + /// + /// Skips a test when the value of an environment variable matches any of the supplied values. + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true)] + public class EnvironmentVariableSkipConditionAttribute : Attribute, ITestCondition + { + private readonly string _variableName; + private readonly string[] _values; + private string _currentValue; + private readonly IEnvironmentVariable _environmentVariable; + + /// + /// Creates a new instance of . + /// + /// Name of the environment variable. + /// Value(s) of the environment variable to match for the test to be skipped + public EnvironmentVariableSkipConditionAttribute(string variableName, params string[] values) + : this(new EnvironmentVariable(), variableName, values) + { + } + + // To enable unit testing + internal EnvironmentVariableSkipConditionAttribute( + IEnvironmentVariable environmentVariable, + string variableName, + params string[] values) + { + if (environmentVariable == null) + { + throw new ArgumentNullException(nameof(environmentVariable)); + } + if (variableName == null) + { + throw new ArgumentNullException(nameof(variableName)); + } + if (values == null) + { + throw new ArgumentNullException(nameof(values)); + } + + _variableName = variableName; + _values = values; + _environmentVariable = environmentVariable; + } + + /// + /// Skips the test only if the value of the variable matches any of the supplied values. Default is True. + /// + public bool SkipOnMatch { get; set; } = true; + + public bool IsMet + { + get + { + _currentValue = _environmentVariable.Get(_variableName); + var hasMatched = _values.Any(value => string.Compare(value, _currentValue, ignoreCase: true) == 0); + + if (SkipOnMatch) + { + return hasMatched; + } + else + { + return !hasMatched; + } + } + } + + public string SkipReason + { + get + { + var value = _currentValue == null ? "(null)" : _currentValue; + return $"Test skipped on environment variable with name '{_variableName}' and value '{value}' " + + $"for the '{nameof(SkipOnMatch)}' value of '{SkipOnMatch}'."; + } + } + + private struct EnvironmentVariable : IEnvironmentVariable + { + public string Get(string name) + { + return Environment.GetEnvironmentVariable(name); + } + } + } +} diff --git a/src/Testing/src/xunit/FrameworkSkipConditionAttribute.cs b/src/Testing/src/xunit/FrameworkSkipConditionAttribute.cs new file mode 100644 index 0000000000..168076a434 --- /dev/null +++ b/src/Testing/src/xunit/FrameworkSkipConditionAttribute.cs @@ -0,0 +1,57 @@ +// 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; + +namespace Microsoft.AspNetCore.Testing.xunit +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class FrameworkSkipConditionAttribute : Attribute, ITestCondition + { + private readonly RuntimeFrameworks _excludedFrameworks; + + public FrameworkSkipConditionAttribute(RuntimeFrameworks excludedFrameworks) + { + _excludedFrameworks = excludedFrameworks; + } + + public bool IsMet + { + get + { + return CanRunOnThisFramework(_excludedFrameworks); + } + } + + public string SkipReason { get; set; } = "Test cannot run on this runtime framework."; + + private static bool CanRunOnThisFramework(RuntimeFrameworks excludedFrameworks) + { + if (excludedFrameworks == RuntimeFrameworks.None) + { + return true; + } + +#if NET461 || NET46 + if (excludedFrameworks.HasFlag(RuntimeFrameworks.Mono) && + TestPlatformHelper.IsMono) + { + return false; + } + + if (excludedFrameworks.HasFlag(RuntimeFrameworks.CLR)) + { + return false; + } +#elif NETSTANDARD2_0 + if (excludedFrameworks.HasFlag(RuntimeFrameworks.CoreCLR)) + { + return false; + } +#else +#error Target frameworks need to be updated. +#endif + return true; + } + } +} \ No newline at end of file diff --git a/src/Testing/src/xunit/IEnvironmentVariable.cs b/src/Testing/src/xunit/IEnvironmentVariable.cs new file mode 100644 index 0000000000..068c210611 --- /dev/null +++ b/src/Testing/src/xunit/IEnvironmentVariable.cs @@ -0,0 +1,10 @@ +// 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. + +namespace Microsoft.AspNetCore.Testing.xunit +{ + internal interface IEnvironmentVariable + { + string Get(string name); + } +} diff --git a/src/Testing/src/xunit/ITestCondition.cs b/src/Testing/src/xunit/ITestCondition.cs new file mode 100644 index 0000000000..bb6ff1f031 --- /dev/null +++ b/src/Testing/src/xunit/ITestCondition.cs @@ -0,0 +1,12 @@ +// 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. + +namespace Microsoft.AspNetCore.Testing.xunit +{ + public interface ITestCondition + { + bool IsMet { get; } + + string SkipReason { get; } + } +} \ No newline at end of file diff --git a/src/Testing/src/xunit/MinimumOsVersionAttribute.cs b/src/Testing/src/xunit/MinimumOsVersionAttribute.cs new file mode 100644 index 0000000000..89e3b19556 --- /dev/null +++ b/src/Testing/src/xunit/MinimumOsVersionAttribute.cs @@ -0,0 +1,111 @@ +// 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.Runtime.InteropServices; +using Microsoft.Win32; + +namespace Microsoft.AspNetCore.Testing.xunit +{ + /// + /// Skips a test if the OS is the given type (Windows) and the OS version is less than specified. + /// E.g. Specifying Window 10.0 skips on Win 8, but not on Linux. Combine with OSSkipConditionAttribute as needed. + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true)] + public class MinimumOSVersionAttribute : Attribute, ITestCondition + { + private readonly OperatingSystems _excludedOperatingSystem; + private readonly Version _minVersion; + private readonly OperatingSystems _osPlatform; + private readonly Version _osVersion; + + public MinimumOSVersionAttribute(OperatingSystems operatingSystem, string minVersion) : + this( + operatingSystem, + GetCurrentOS(), + GetCurrentOSVersion(), + Version.Parse(minVersion)) + { + } + + // to enable unit testing + internal MinimumOSVersionAttribute( + OperatingSystems operatingSystem, OperatingSystems osPlatform, Version osVersion, Version minVersion) + { + if (operatingSystem != OperatingSystems.Windows) + { + throw new NotImplementedException("Min version support is only implemented for Windows."); + } + _excludedOperatingSystem = operatingSystem; + _minVersion = minVersion; + _osPlatform = osPlatform; + _osVersion = osVersion; + + SkipReason = $"This test requires {_excludedOperatingSystem} {_minVersion} or later."; + } + + public bool IsMet + { + get + { + // Do not skip other OS's, Use OSSkipConditionAttribute or a separate MinimumOSVersionAttribute for that. + if (_osPlatform != _excludedOperatingSystem) + { + return true; + } + + return _osVersion >= _minVersion; + } + } + + public string SkipReason { get; set; } + + private static OperatingSystems GetCurrentOS() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return OperatingSystems.Windows; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return OperatingSystems.Linux; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return OperatingSystems.MacOSX; + } + throw new PlatformNotSupportedException(); + } + + private static Version GetCurrentOSVersion() + { + // currently not used on other OS's + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Win10+ + var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"); + var major = key.GetValue("CurrentMajorVersionNumber") as int?; + var minor = key.GetValue("CurrentMinorVersionNumber") as int?; + + if (major.HasValue && minor.HasValue) + { + return new Version(major.Value, minor.Value); + } + + // CurrentVersion doesn't work past Win8.1 + var current = key.GetValue("CurrentVersion") as string; + if (!string.IsNullOrEmpty(current) && Version.TryParse(current, out var currentVersion)) + { + return currentVersion; + } + + // Environment.OSVersion doesn't work past Win8. + return Environment.OSVersion.Version; + } + else + { + return new Version(); + } + } + } +} diff --git a/src/Testing/src/xunit/OSSkipConditionAttribute.cs b/src/Testing/src/xunit/OSSkipConditionAttribute.cs new file mode 100644 index 0000000000..9996510718 --- /dev/null +++ b/src/Testing/src/xunit/OSSkipConditionAttribute.cs @@ -0,0 +1,99 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; + +namespace Microsoft.AspNetCore.Testing.xunit +{ + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true)] + public class OSSkipConditionAttribute : Attribute, ITestCondition + { + private readonly OperatingSystems _excludedOperatingSystem; + private readonly IEnumerable _excludedVersions; + private readonly OperatingSystems _osPlatform; + private readonly string _osVersion; + + public OSSkipConditionAttribute(OperatingSystems operatingSystem, params string[] versions) : + this( + operatingSystem, + GetCurrentOS(), + GetCurrentOSVersion(), + versions) + { + } + + // to enable unit testing + internal OSSkipConditionAttribute( + OperatingSystems operatingSystem, OperatingSystems osPlatform, string osVersion, params string[] versions) + { + _excludedOperatingSystem = operatingSystem; + _excludedVersions = versions ?? Enumerable.Empty(); + _osPlatform = osPlatform; + _osVersion = osVersion; + } + + public bool IsMet + { + get + { + var currentOSInfo = new OSInfo() + { + OperatingSystem = _osPlatform, + Version = _osVersion, + }; + + var skip = (_excludedOperatingSystem & currentOSInfo.OperatingSystem) == currentOSInfo.OperatingSystem; + if (_excludedVersions.Any()) + { + skip = skip + && _excludedVersions.Any(ex => _osVersion.StartsWith(ex, StringComparison.OrdinalIgnoreCase)); + } + + // Since a test would be excuted only if 'IsMet' is true, return false if we want to skip + return !skip; + } + } + + public string SkipReason { get; set; } = "Test cannot run on this operating system."; + + static private OperatingSystems GetCurrentOS() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return OperatingSystems.Windows; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return OperatingSystems.Linux; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return OperatingSystems.MacOSX; + } + throw new PlatformNotSupportedException(); + } + + static private string GetCurrentOSVersion() + { + // currently not used on other OS's + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return Environment.OSVersion.Version.ToString(); + } + else + { + return string.Empty; + } + } + + private class OSInfo + { + public OperatingSystems OperatingSystem { get; set; } + + public string Version { get; set; } + } + } +} diff --git a/src/Testing/src/xunit/OperatingSystems.cs b/src/Testing/src/xunit/OperatingSystems.cs new file mode 100644 index 0000000000..c575d3e197 --- /dev/null +++ b/src/Testing/src/xunit/OperatingSystems.cs @@ -0,0 +1,15 @@ +// 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; + +namespace Microsoft.AspNetCore.Testing.xunit +{ + [Flags] + public enum OperatingSystems + { + Linux = 1, + MacOSX = 2, + Windows = 4, + } +} \ No newline at end of file diff --git a/src/Testing/src/xunit/RuntimeFrameworks.cs b/src/Testing/src/xunit/RuntimeFrameworks.cs new file mode 100644 index 0000000000..2ec5ea7ec1 --- /dev/null +++ b/src/Testing/src/xunit/RuntimeFrameworks.cs @@ -0,0 +1,16 @@ +// 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; + +namespace Microsoft.AspNetCore.Testing.xunit +{ + [Flags] + public enum RuntimeFrameworks + { + None = 0, + Mono = 1 << 0, + CLR = 1 << 1, + CoreCLR = 1 << 2 + } +} \ No newline at end of file diff --git a/src/Testing/src/xunit/SkippedTestCase.cs b/src/Testing/src/xunit/SkippedTestCase.cs new file mode 100644 index 0000000000..c2e15fa640 --- /dev/null +++ b/src/Testing/src/xunit/SkippedTestCase.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; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing.xunit +{ + public class SkippedTestCase : XunitTestCase + { + private string _skipReason; + + [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] + public SkippedTestCase() : base() + { + } + + public SkippedTestCase(string skipReason, IMessageSink diagnosticMessageSink, TestMethodDisplay defaultMethodDisplay, ITestMethod testMethod, object[] testMethodArguments = null) + : base(diagnosticMessageSink, defaultMethodDisplay, testMethod, testMethodArguments) + { + _skipReason = skipReason; + } + + protected override string GetSkipReason(IAttributeInfo factAttribute) + => _skipReason ?? base.GetSkipReason(factAttribute); + + public override void Deserialize(IXunitSerializationInfo data) + { + base.Deserialize(data); + _skipReason = data.GetValue(nameof(_skipReason)); + } + + public override void Serialize(IXunitSerializationInfo data) + { + base.Serialize(data); + data.AddValue(nameof(_skipReason), _skipReason); + } + } +} \ No newline at end of file diff --git a/src/Testing/src/xunit/TestMethodExtensions.cs b/src/Testing/src/xunit/TestMethodExtensions.cs new file mode 100644 index 0000000000..5ec3bb4ec3 --- /dev/null +++ b/src/Testing/src/xunit/TestMethodExtensions.cs @@ -0,0 +1,34 @@ +// 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.Linq; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing.xunit +{ + public static class TestMethodExtensions + { + public static string EvaluateSkipConditions(this ITestMethod testMethod) + { + var testClass = testMethod.TestClass.Class; + var assembly = testMethod.TestClass.TestCollection.TestAssembly.Assembly; + var conditionAttributes = testMethod.Method + .GetCustomAttributes(typeof(ITestCondition)) + .Concat(testClass.GetCustomAttributes(typeof(ITestCondition))) + .Concat(assembly.GetCustomAttributes(typeof(ITestCondition))) + .OfType() + .Select(attributeInfo => attributeInfo.Attribute); + + foreach (ITestCondition condition in conditionAttributes) + { + if (!condition.IsMet) + { + return condition.SkipReason; + } + } + + return null; + } + } +} diff --git a/src/Testing/src/xunit/WindowsVersions.cs b/src/Testing/src/xunit/WindowsVersions.cs new file mode 100644 index 0000000000..ff8312b363 --- /dev/null +++ b/src/Testing/src/xunit/WindowsVersions.cs @@ -0,0 +1,18 @@ +// 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. + +namespace Microsoft.AspNetCore.Testing.xunit +{ + public static class WindowsVersions + { + public const string Win7 = "6.1"; + + public const string Win2008R2 = Win7; + + public const string Win8 = "6.2"; + + public const string Win81 = "6.3"; + + public const string Win10 = "10.0"; + } +} diff --git a/src/Testing/test/CollectingEventListenerTest.cs b/src/Testing/test/CollectingEventListenerTest.cs new file mode 100644 index 0000000000..8f131982f0 --- /dev/null +++ b/src/Testing/test/CollectingEventListenerTest.cs @@ -0,0 +1,87 @@ +using System.Diagnostics.Tracing; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Testing.Tracing; +using Xunit; + +namespace Microsoft.AspNetCore.Testing.Tests +{ + // We are verifying here that when event listener tests are spread among multiple classes, they still + // work, even when run in parallel. To do that we have a bunch of tests in different classes (since + // that affects parallelism) and do some Task.Yielding in them. + public class CollectingEventListenerTests + { + public abstract class CollectingTestBase : EventSourceTestBase + { + [Fact] + public async Task CollectingEventListenerTest() + { + CollectFrom("Microsoft-AspNetCore-Testing-Test"); + + await Task.Yield(); + TestEventSource.Log.Test(); + await Task.Yield(); + TestEventSource.Log.TestWithPayload(42, 4.2); + await Task.Yield(); + + var events = GetEvents(); + EventAssert.Collection(events, + EventAssert.Event(1, "Test", EventLevel.Informational), + EventAssert.Event(2, "TestWithPayload", EventLevel.Verbose) + .Payload("payload1", 42) + .Payload("payload2", 4.2)); + } + } + + // These tests are designed to interfere with the collecting ones by running in parallel and writing events + public abstract class NonCollectingTestBase + { + [Fact] + public async Task CollectingEventListenerTest() + { + await Task.Yield(); + TestEventSource.Log.Test(); + await Task.Yield(); + TestEventSource.Log.TestWithPayload(42, 4.2); + await Task.Yield(); + } + } + + public class CollectingTests + { + public class A : CollectingTestBase { } + public class B : CollectingTestBase { } + public class C : CollectingTestBase { } + public class D : CollectingTestBase { } + public class E : CollectingTestBase { } + public class F : CollectingTestBase { } + public class G : CollectingTestBase { } + } + + public class NonCollectingTests + { + public class A : NonCollectingTestBase { } + public class B : NonCollectingTestBase { } + public class C : NonCollectingTestBase { } + public class D : NonCollectingTestBase { } + public class E : NonCollectingTestBase { } + public class F : NonCollectingTestBase { } + public class G : NonCollectingTestBase { } + } + } + + [EventSource(Name = "Microsoft-AspNetCore-Testing-Test")] + public class TestEventSource : EventSource + { + public static readonly TestEventSource Log = new TestEventSource(); + + private TestEventSource() + { + } + + [Event(eventId: 1, Level = EventLevel.Informational, Message = "Test")] + public void Test() => WriteEvent(1); + + [Event(eventId: 2, Level = EventLevel.Verbose, Message = "Test")] + public void TestWithPayload(int payload1, double payload2) => WriteEvent(2, payload1, payload2); + } +} diff --git a/src/Testing/test/ConditionalFactTest.cs b/src/Testing/test/ConditionalFactTest.cs new file mode 100644 index 0000000000..a04eb1731d --- /dev/null +++ b/src/Testing/test/ConditionalFactTest.cs @@ -0,0 +1,60 @@ +// 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 Microsoft.AspNetCore.Testing.xunit; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class ConditionalFactTest : IClassFixture + { + public ConditionalFactTest(ConditionalFactAsserter collector) + { + Asserter = collector; + } + + private ConditionalFactAsserter Asserter { get; } + + [Fact] + public void TestAlwaysRun() + { + // This is required to ensure that the type at least gets initialized. + Assert.True(true); + } + + [ConditionalFact(Skip = "Test is always skipped.")] + public void ConditionalFactSkip() + { + Assert.True(false, "This test should always be skipped."); + } + +#if NETCOREAPP2_2 + [ConditionalFact] + [FrameworkSkipCondition(RuntimeFrameworks.CLR)] + public void ThisTestMustRunOnCoreCLR() + { + Asserter.TestRan = true; + } +#elif NET461 || NET46 + [ConditionalFact] + [FrameworkSkipCondition(RuntimeFrameworks.CoreCLR)] + public void ThisTestMustRunOnCLR() + { + Asserter.TestRan = true; + } +#else +#error Target frameworks need to be updated. +#endif + + public class ConditionalFactAsserter : IDisposable + { + public bool TestRan { get; set; } + + public void Dispose() + { + Assert.True(TestRan, "If this assertion fails, a conditional fact wasn't discovered."); + } + } + } +} \ No newline at end of file diff --git a/src/Testing/test/ConditionalTheoryTest.cs b/src/Testing/test/ConditionalTheoryTest.cs new file mode 100644 index 0000000000..1181f1365a --- /dev/null +++ b/src/Testing/test/ConditionalTheoryTest.cs @@ -0,0 +1,156 @@ +// 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 Microsoft.AspNetCore.Testing.xunit; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Testing +{ + public class ConditionalTheoryTest : IClassFixture + { + public ConditionalTheoryTest(ConditionalTheoryAsserter asserter) + { + Asserter = asserter; + } + + public ConditionalTheoryAsserter Asserter { get; } + + [ConditionalTheory(Skip = "Test is always skipped.")] + [InlineData(0)] + public void ConditionalTheorySkip(int arg) + { + Assert.True(false, "This test should always be skipped."); + } + + private static int _conditionalTheoryRuns = 0; + + [ConditionalTheory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2, Skip = "Skip these data")] + public void ConditionalTheoryRunOncePerDataLine(int arg) + { + _conditionalTheoryRuns++; + Assert.True(_conditionalTheoryRuns <= 2, $"Theory should run 2 times, but ran {_conditionalTheoryRuns} times."); + } + + [ConditionalTheory, Trait("Color", "Blue")] + [InlineData(1)] + public void ConditionalTheoriesShouldPreserveTraits(int arg) + { + Assert.True(true); + } + + [ConditionalTheory(Skip = "Skip this")] + [MemberData(nameof(GetInts))] + public void ConditionalTheoriesWithSkippedMemberData(int arg) + { + Assert.True(false, "This should never run"); + } + + private static int _conditionalMemberDataRuns = 0; + + [ConditionalTheory] + [InlineData(4)] + [MemberData(nameof(GetInts))] + public void ConditionalTheoriesWithMemberData(int arg) + { + _conditionalMemberDataRuns++; + Assert.True(_conditionalTheoryRuns <= 3, $"Theory should run 2 times, but ran {_conditionalMemberDataRuns} times."); + } + + public static TheoryData GetInts + => new TheoryData { 0, 1 }; + + [ConditionalTheory] + [OSSkipCondition(OperatingSystems.Windows)] + [OSSkipCondition(OperatingSystems.MacOSX)] + [OSSkipCondition(OperatingSystems.Linux)] + [MemberData(nameof(GetActionTestData))] + public void ConditionalTheoryWithFuncs(Func func) + { + Assert.True(false, "This should never run"); + } + + [Fact] + public void TestAlwaysRun() + { + // This is required to ensure that this type at least gets initialized. + Assert.True(true); + } + +#if NETCOREAPP2_2 + [ConditionalTheory] + [FrameworkSkipCondition(RuntimeFrameworks.CLR)] + [MemberData(nameof(GetInts))] + public void ThisTestMustRunOnCoreCLR(int value) + { + Asserter.TestRan = true; + } +#elif NET461 || NET46 + [ConditionalTheory] + [FrameworkSkipCondition(RuntimeFrameworks.CoreCLR)] + [MemberData(nameof(GetInts))] + public void ThisTestMustRunOnCLR(int value) + { + Asserter.TestRan = true; + } +#else +#error Target frameworks need to be updated. +#endif + + public static TheoryData> GetActionTestData + => new TheoryData> + { + (i) => i * 1 + }; + + public class ConditionalTheoryAsserter : IDisposable + { + public bool TestRan { get; set; } + + public void Dispose() + { + Assert.True(TestRan, "If this assertion fails, a conditional theory wasn't discovered."); + } + } + + [ConditionalTheory] + [MemberData(nameof(SkippableData))] + public void WithSkipableData(Skippable skippable) + { + Assert.Null(skippable.Skip); + Assert.Equal(1, skippable.Data); + } + + public static TheoryData SkippableData => new TheoryData + { + new Skippable() { Data = 1 }, + new Skippable() { Data = 2, Skip = "This row should be skipped." } + }; + + public class Skippable : IXunitSerializable + { + public Skippable() { } + public int Data { get; set; } + public string Skip { get; set; } + + public void Serialize(IXunitSerializationInfo info) + { + info.AddValue(nameof(Data), Data, typeof(int)); + } + + public void Deserialize(IXunitSerializationInfo info) + { + Data = info.GetValue(nameof(Data)); + } + + public override string ToString() + { + return Data.ToString(); + } + } + } +} diff --git a/src/Testing/test/DockerTests.cs b/src/Testing/test/DockerTests.cs new file mode 100644 index 0000000000..c66fdd679c --- /dev/null +++ b/src/Testing/test/DockerTests.cs @@ -0,0 +1,21 @@ +// 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.Runtime.InteropServices; +using Microsoft.AspNetCore.Testing.xunit; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class DockerTests + { + [ConditionalFact] + [DockerOnly] + [Trait("Docker", "true")] + public void DoesNotRunOnWindows() + { + Assert.False(RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + } + } +} diff --git a/src/Testing/test/EnvironmentVariableSkipConditionTest.cs b/src/Testing/test/EnvironmentVariableSkipConditionTest.cs new file mode 100644 index 0000000000..b536ae56f7 --- /dev/null +++ b/src/Testing/test/EnvironmentVariableSkipConditionTest.cs @@ -0,0 +1,166 @@ +// 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 Xunit; + +namespace Microsoft.AspNetCore.Testing.xunit +{ + public class EnvironmentVariableSkipConditionTest + { + private readonly string _skipReason = "Test skipped on environment variable with name '{0}' and value '{1}'" + + $" for the '{nameof(EnvironmentVariableSkipConditionAttribute.SkipOnMatch)}' value of '{{2}}'."; + + [Theory] + [InlineData("false")] + [InlineData("")] + [InlineData(null)] + public void IsMet_DoesNotMatch(string environmentVariableValue) + { + // Arrange + var attribute = new EnvironmentVariableSkipConditionAttribute( + new TestEnvironmentVariable(environmentVariableValue), + "Run", + "true"); + + // Act + var isMet = attribute.IsMet; + + // Assert + Assert.False(isMet); + } + + [Theory] + [InlineData("True")] + [InlineData("TRUE")] + [InlineData("true")] + public void IsMet_DoesCaseInsensitiveMatch_OnValue(string environmentVariableValue) + { + // Arrange + var attribute = new EnvironmentVariableSkipConditionAttribute( + new TestEnvironmentVariable(environmentVariableValue), + "Run", + "true"); + + // Act + var isMet = attribute.IsMet; + + // Assert + Assert.True(isMet); + Assert.Equal( + string.Format(_skipReason, "Run", environmentVariableValue, attribute.SkipOnMatch), + attribute.SkipReason); + } + + [Fact] + public void IsMet_DoesSuccessfulMatch_OnNull() + { + // Arrange + var attribute = new EnvironmentVariableSkipConditionAttribute( + new TestEnvironmentVariable(null), + "Run", + "true", null); // skip the test when the variable 'Run' is explicitly set to 'true' or is null (default) + + // Act + var isMet = attribute.IsMet; + + // Assert + Assert.True(isMet); + Assert.Equal( + string.Format(_skipReason, "Run", "(null)", attribute.SkipOnMatch), + attribute.SkipReason); + } + + [Theory] + [InlineData("false")] + [InlineData("")] + [InlineData(null)] + public void IsMet_MatchesOnMultipleSkipValues(string environmentVariableValue) + { + // Arrange + var attribute = new EnvironmentVariableSkipConditionAttribute( + new TestEnvironmentVariable(environmentVariableValue), + "Run", + "false", "", null); + + // Act + var isMet = attribute.IsMet; + + // Assert + Assert.True(isMet); + } + + [Fact] + public void IsMet_DoesNotMatch_OnMultipleSkipValues() + { + // Arrange + var attribute = new EnvironmentVariableSkipConditionAttribute( + new TestEnvironmentVariable("100"), + "Build", + "125", "126"); + + // Act + var isMet = attribute.IsMet; + + // Assert + Assert.False(isMet); + } + + [Theory] + [InlineData("CentOS")] + [InlineData(null)] + [InlineData("")] + public void IsMet_Matches_WhenSkipOnMatchIsFalse(string environmentVariableValue) + { + // Arrange + var attribute = new EnvironmentVariableSkipConditionAttribute( + new TestEnvironmentVariable(environmentVariableValue), + "LinuxFlavor", + "Ubuntu14.04") + { + // Example: Run this test on all OSes except on "Ubuntu14.04" + SkipOnMatch = false + }; + + // Act + var isMet = attribute.IsMet; + + // Assert + Assert.True(isMet); + } + + [Fact] + public void IsMet_DoesNotMatch_WhenSkipOnMatchIsFalse() + { + // Arrange + var attribute = new EnvironmentVariableSkipConditionAttribute( + new TestEnvironmentVariable("Ubuntu14.04"), + "LinuxFlavor", + "Ubuntu14.04") + { + // Example: Run this test on all OSes except on "Ubuntu14.04" + SkipOnMatch = false + }; + + // Act + var isMet = attribute.IsMet; + + // Assert + Assert.False(isMet); + } + + private struct TestEnvironmentVariable : IEnvironmentVariable + { + public TestEnvironmentVariable(string value) + { + Value = value; + } + + public string Value { get; private set; } + + public string Get(string name) + { + return Value; + } + } + } +} diff --git a/src/Testing/test/ExceptionAssertTest.cs b/src/Testing/test/ExceptionAssertTest.cs new file mode 100644 index 0000000000..aa7354dca8 --- /dev/null +++ b/src/Testing/test/ExceptionAssertTest.cs @@ -0,0 +1,39 @@ +// 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 Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class ExceptionAssertTest + { + [Fact] + [ReplaceCulture("fr-FR", "fr-FR")] + public void AssertArgumentNullOrEmptyString_WorksInNonEnglishCultures() + { + // Arrange + Action action = () => + { + throw new ArgumentException("Value cannot be null or an empty string.", "foo"); + }; + + // Act and Assert + ExceptionAssert.ThrowsArgumentNullOrEmptyString(action, "foo"); + } + + [Fact] + [ReplaceCulture("fr-FR", "fr-FR")] + public void AssertArgumentOutOfRangeException_WorksInNonEnglishCultures() + { + // Arrange + Action action = () => + { + throw new ArgumentOutOfRangeException("foo", 10, "exception message."); + }; + + // Act and Assert + ExceptionAssert.ThrowsArgumentOutOfRange(action, "foo", "exception message.", 10); + } + } +} \ No newline at end of file diff --git a/src/Testing/test/HttpClientSlimTest.cs b/src/Testing/test/HttpClientSlimTest.cs new file mode 100644 index 0000000000..42b19ece08 --- /dev/null +++ b/src/Testing/test/HttpClientSlimTest.cs @@ -0,0 +1,117 @@ +// 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.Http; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class HttpClientSlimTest + { + private static byte[] _defaultResponse = Encoding.ASCII.GetBytes("test"); + + [Fact] + public async Task GetStringAsyncHttp() + { + using (var host = StartHost(out var address)) + { + Assert.Equal("test", await HttpClientSlim.GetStringAsync(address)); + } + } + + [Fact] + public async Task GetStringAsyncThrowsForErrorResponse() + { + using (var host = StartHost(out var address, statusCode: 500)) + { + await Assert.ThrowsAnyAsync(() => HttpClientSlim.GetStringAsync(address)); + } + } + + [Fact] + public async Task PostAsyncHttp() + { + using (var host = StartHost(out var address, handler: context => context.Request.InputStream.CopyToAsync(context.Response.OutputStream))) + { + Assert.Equal("test post", await HttpClientSlim.PostAsync(address, new StringContent("test post"))); + } + } + + [Fact] + public async Task PostAsyncThrowsForErrorResponse() + { + using (var host = StartHost(out var address, statusCode: 500)) + { + await Assert.ThrowsAnyAsync( + () => HttpClientSlim.PostAsync(address, new StringContent(""))); + } + } + + [Fact] + public void Ipv6ScopeIdsFilteredOut() + { + var requestUri = new Uri("http://[fe80::5d2a:d070:6fd6:1bac%7]:5003/"); + Assert.Equal("[fe80::5d2a:d070:6fd6:1bac]:5003", HttpClientSlim.GetHost(requestUri)); + } + + [Fact] + public void GetHostExcludesDefaultPort() + { + var requestUri = new Uri("http://[fe80::5d2a:d070:6fd6:1bac%7]:80/"); + Assert.Equal("[fe80::5d2a:d070:6fd6:1bac]", HttpClientSlim.GetHost(requestUri)); + } + + private HttpListener StartHost(out string address, int statusCode = 200, Func handler = null) + { + var listener = new HttpListener(); + var random = new Random(); + address = null; + + for (var i = 0; i < 10; i++) + { + try + { + // HttpListener doesn't support requesting port 0 (dynamic). + // Requesting port 0 from Sockets and then passing that to HttpListener is racy. + // Just keep trying until we find a free one. + address = $"http://127.0.0.1:{random.Next(1024, ushort.MaxValue)}/"; + listener.Prefixes.Add(address); + listener.Start(); + break; + } + catch (HttpListenerException) + { + // Address in use + listener.Close(); + listener = new HttpListener(); + } + } + + Assert.True(listener.IsListening, "IsListening"); + + _ = listener.GetContextAsync().ContinueWith(async task => + { + var context = task.Result; + context.Response.StatusCode = statusCode; + + if (handler == null) + { + await context.Response.OutputStream.WriteAsync(_defaultResponse, 0, _defaultResponse.Length); + } + else + { + await handler(context); + } + + context.Response.Close(); + }); + + return listener; + } + } +} diff --git a/src/Testing/test/Microsoft.AspNetCore.Testing.Tests.csproj b/src/Testing/test/Microsoft.AspNetCore.Testing.Tests.csproj new file mode 100644 index 0000000000..3fdd9ff379 --- /dev/null +++ b/src/Testing/test/Microsoft.AspNetCore.Testing.Tests.csproj @@ -0,0 +1,28 @@ + + + + $(StandardTestTfms) + + + $(NoWarn);xUnit1004 + + $(NoWarn);xUnit1026 + + + + + + + + + + + + + + + + + + + diff --git a/src/Testing/test/OSSkipConditionAttributeTest.cs b/src/Testing/test/OSSkipConditionAttributeTest.cs new file mode 100644 index 0000000000..0120eb7a4c --- /dev/null +++ b/src/Testing/test/OSSkipConditionAttributeTest.cs @@ -0,0 +1,132 @@ +// 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.Runtime.InteropServices; +using Xunit; + +namespace Microsoft.AspNetCore.Testing.xunit +{ + public class OSSkipConditionAttributeTest + { + [Fact] + public void Skips_WhenOnlyOperatingSystemIsSupplied() + { + // Act + var osSkipAttribute = new OSSkipConditionAttribute( + OperatingSystems.Windows, + OperatingSystems.Windows, + "2.5"); + + // Assert + Assert.False(osSkipAttribute.IsMet); + } + + [Fact] + public void DoesNotSkip_WhenOperatingSystemDoesNotMatch() + { + // Act + var osSkipAttribute = new OSSkipConditionAttribute( + OperatingSystems.Linux, + OperatingSystems.Windows, + "2.5"); + + // Assert + Assert.True(osSkipAttribute.IsMet); + } + + [Fact] + public void DoesNotSkip_WhenVersionsDoNotMatch() + { + // Act + var osSkipAttribute = new OSSkipConditionAttribute( + OperatingSystems.Windows, + OperatingSystems.Windows, + "2.5", + "10.0"); + + // Assert + Assert.True(osSkipAttribute.IsMet); + } + + [Fact] + public void DoesNotSkip_WhenOnlyVersionsMatch() + { + // Act + var osSkipAttribute = new OSSkipConditionAttribute( + OperatingSystems.Linux, + OperatingSystems.Windows, + "2.5", + "2.5"); + + // Assert + Assert.True(osSkipAttribute.IsMet); + } + + [Theory] + [InlineData("2.5", "2.5")] + [InlineData("blue", "Blue")] + public void Skips_WhenVersionsMatches(string currentOSVersion, string skipVersion) + { + // Act + var osSkipAttribute = new OSSkipConditionAttribute( + OperatingSystems.Windows, + OperatingSystems.Windows, + currentOSVersion, + skipVersion); + + // Assert + Assert.False(osSkipAttribute.IsMet); + } + + [Fact] + public void Skips_WhenVersionsMatchesOutOfMultiple() + { + // Act + var osSkipAttribute = new OSSkipConditionAttribute( + OperatingSystems.Windows, + OperatingSystems.Windows, + "2.5", + "10.0", "3.4", "2.5"); + + // Assert + Assert.False(osSkipAttribute.IsMet); + } + + [Fact] + public void Skips_BothMacOSXAndLinux() + { + // Act + var osSkipAttributeLinux = new OSSkipConditionAttribute(OperatingSystems.Linux | OperatingSystems.MacOSX, OperatingSystems.Linux, string.Empty); + var osSkipAttributeMacOSX = new OSSkipConditionAttribute(OperatingSystems.Linux | OperatingSystems.MacOSX, OperatingSystems.MacOSX, string.Empty); + + // Assert + Assert.False(osSkipAttributeLinux.IsMet); + Assert.False(osSkipAttributeMacOSX.IsMet); + } + + [Fact] + public void Skips_BothMacOSXAndWindows() + { + // Act + var osSkipAttribute = new OSSkipConditionAttribute(OperatingSystems.Windows | OperatingSystems.MacOSX, OperatingSystems.Windows, string.Empty); + var osSkipAttributeMacOSX = new OSSkipConditionAttribute(OperatingSystems.Windows | OperatingSystems.MacOSX, OperatingSystems.MacOSX, string.Empty); + + // Assert + Assert.False(osSkipAttribute.IsMet); + Assert.False(osSkipAttributeMacOSX.IsMet); + } + + [Fact] + public void Skips_BothWindowsAndLinux() + { + // Act + var osSkipAttribute = new OSSkipConditionAttribute(OperatingSystems.Linux | OperatingSystems.Windows, OperatingSystems.Windows, string.Empty); + var osSkipAttributeLinux = new OSSkipConditionAttribute(OperatingSystems.Linux | OperatingSystems.Windows, OperatingSystems.Linux, string.Empty); + + // Assert + Assert.False(osSkipAttribute.IsMet); + Assert.False(osSkipAttributeLinux.IsMet); + } + } +} diff --git a/src/Testing/test/OSSkipConditionTest.cs b/src/Testing/test/OSSkipConditionTest.cs new file mode 100644 index 0000000000..2d76f2c2cd --- /dev/null +++ b/src/Testing/test/OSSkipConditionTest.cs @@ -0,0 +1,116 @@ +// 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.Runtime.InteropServices; +using Xunit; + +namespace Microsoft.AspNetCore.Testing.xunit +{ + public class OSSkipConditionTest + { + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux)] + public void TestSkipLinux() + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux), + "Test should not be running on Linux"); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.MacOSX)] + public void TestSkipMacOSX() + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX), + "Test should not be running on MacOSX."); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Windows, WindowsVersions.Win7, WindowsVersions.Win2008R2)] + public void RunTest_DoesNotRunOnWin7OrWin2008R2() + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + Environment.OSVersion.Version.ToString().StartsWith("6.1"), + "Test should not be running on Win7 or Win2008R2."); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Windows)] + public void TestSkipWindows() + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + "Test should not be running on Windows."); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public void TestSkipLinuxAndMacOSX() + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux), + "Test should not be running on Linux."); + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX), + "Test should not be running on MacOSX."); + } + + [ConditionalTheory] + [OSSkipCondition(OperatingSystems.Linux)] + [InlineData(1)] + public void TestTheorySkipLinux(int arg) + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux), + "Test should not be running on Linux"); + } + + [ConditionalTheory] + [OSSkipCondition(OperatingSystems.MacOSX)] + [InlineData(1)] + public void TestTheorySkipMacOS(int arg) + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX), + "Test should not be running on MacOSX."); + } + + [ConditionalTheory] + [OSSkipCondition(OperatingSystems.Windows)] + [InlineData(1)] + public void TestTheorySkipWindows(int arg) + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + "Test should not be running on Windows."); + } + + [ConditionalTheory] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + [InlineData(1)] + public void TestTheorySkipLinuxAndMacOSX(int arg) + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux), + "Test should not be running on Linux."); + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX), + "Test should not be running on MacOSX."); + } + } + + [OSSkipCondition(OperatingSystems.Windows)] + public class OSSkipConditionClassTest + { + [ConditionalFact] + public void TestSkipClassWindows() + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + "Test should not be running on Windows."); + } + } +} diff --git a/src/Testing/test/ReplaceCultureAttributeTest.cs b/src/Testing/test/ReplaceCultureAttributeTest.cs new file mode 100644 index 0000000000..6b8df346c9 --- /dev/null +++ b/src/Testing/test/ReplaceCultureAttributeTest.cs @@ -0,0 +1,66 @@ +// 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.Globalization; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class RepalceCultureAttributeTest + { + [Fact] + public void DefaultsTo_EnGB_EnUS() + { + // Arrange + var culture = new CultureInfo("en-GB"); + var uiCulture = new CultureInfo("en-US"); + + // Act + var replaceCulture = new ReplaceCultureAttribute(); + + // Assert + Assert.Equal(culture, replaceCulture.Culture); + Assert.Equal(uiCulture, replaceCulture.UICulture); + } + + [Fact] + public void UsesSuppliedCultureAndUICulture() + { + // Arrange + var culture = "de-DE"; + var uiCulture = "fr-CA"; + + // Act + var replaceCulture = new ReplaceCultureAttribute(culture, uiCulture); + + // Assert + Assert.Equal(new CultureInfo(culture), replaceCulture.Culture); + Assert.Equal(new CultureInfo(uiCulture), replaceCulture.UICulture); + } + + [Fact] + public void BeforeAndAfterTest_ReplacesCulture() + { + // Arrange + var originalCulture = CultureInfo.CurrentCulture; + var originalUICulture = CultureInfo.CurrentUICulture; + var culture = "de-DE"; + var uiCulture = "fr-CA"; + var replaceCulture = new ReplaceCultureAttribute(culture, uiCulture); + + // Act + replaceCulture.Before(methodUnderTest: null); + + // Assert + Assert.Equal(new CultureInfo(culture), CultureInfo.CurrentCulture); + Assert.Equal(new CultureInfo(uiCulture), CultureInfo.CurrentUICulture); + + // Act + replaceCulture.After(methodUnderTest: null); + + // Assert + Assert.Equal(originalCulture, CultureInfo.CurrentCulture); + Assert.Equal(originalUICulture, CultureInfo.CurrentUICulture); + } + } +} \ No newline at end of file diff --git a/src/Testing/test/TaskExtensionsTest.cs b/src/Testing/test/TaskExtensionsTest.cs new file mode 100644 index 0000000000..f7ad603df5 --- /dev/null +++ b/src/Testing/test/TaskExtensionsTest.cs @@ -0,0 +1,18 @@ +// 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.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class TaskExtensionsTest + { + [Fact] + public async Task TimeoutAfterTest() + { + await Assert.ThrowsAsync(async () => await Task.Delay(1000).TimeoutAfter(TimeSpan.FromMilliseconds(50))); + } + } +} diff --git a/src/Testing/test/TestPathUtilitiesTest.cs b/src/Testing/test/TestPathUtilitiesTest.cs new file mode 100644 index 0000000000..0c9a7c5ee4 --- /dev/null +++ b/src/Testing/test/TestPathUtilitiesTest.cs @@ -0,0 +1,31 @@ +// 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 Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class TestPathUtilitiesTest + { + [Fact] + public void GetSolutionRootDirectory_ResolvesSolutionRoot() + { + // Directory.GetCurrentDirectory() gives: + // Testing\test\Microsoft.AspNetCore.Testing.Tests\bin\Debug\netcoreapp2.0 + // Testing\test\Microsoft.AspNetCore.Testing.Tests\bin\Debug\net461 + // Testing\test\Microsoft.AspNetCore.Testing.Tests\bin\Debug\net46 + var expectedPath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "..", "..", "..")); + + Assert.Equal(expectedPath, TestPathUtilities.GetSolutionRootDirectory("Extensions")); + } + + [Fact] + public void GetSolutionRootDirectory_Throws_IfNotFound() + { + var exception = Assert.Throws(() => TestPathUtilities.GetSolutionRootDirectory("NotTesting")); + Assert.Equal($"Solution file NotTesting.sln could not be found in {AppContext.BaseDirectory} or its parent directories.", exception.Message); + } + } +} diff --git a/src/Testing/test/TestPlatformHelperTest.cs b/src/Testing/test/TestPlatformHelperTest.cs new file mode 100644 index 0000000000..8e35e164d5 --- /dev/null +++ b/src/Testing/test/TestPlatformHelperTest.cs @@ -0,0 +1,55 @@ +// 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 Microsoft.AspNetCore.Testing.xunit; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class TestPlatformHelperTest + { + [ConditionalFact] + [OSSkipCondition(OperatingSystems.MacOSX)] + [OSSkipCondition(OperatingSystems.Windows)] + public void IsLinux_TrueOnLinux() + { + Assert.True(TestPlatformHelper.IsLinux); + Assert.False(TestPlatformHelper.IsMac); + Assert.False(TestPlatformHelper.IsWindows); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux)] + [OSSkipCondition(OperatingSystems.Windows)] + public void IsMac_TrueOnMac() + { + Assert.False(TestPlatformHelper.IsLinux); + Assert.True(TestPlatformHelper.IsMac); + Assert.False(TestPlatformHelper.IsWindows); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux)] + [OSSkipCondition(OperatingSystems.MacOSX)] + public void IsWindows_TrueOnWindows() + { + Assert.False(TestPlatformHelper.IsLinux); + Assert.False(TestPlatformHelper.IsMac); + Assert.True(TestPlatformHelper.IsWindows); + } + + [ConditionalFact] + [FrameworkSkipCondition(RuntimeFrameworks.CLR | RuntimeFrameworks.CoreCLR | RuntimeFrameworks.None)] + public void IsMono_TrueOnMono() + { + Assert.True(TestPlatformHelper.IsMono); + } + + [ConditionalFact] + [FrameworkSkipCondition(RuntimeFrameworks.Mono)] + public void IsMono_FalseElsewhere() + { + Assert.False(TestPlatformHelper.IsMono); + } + } +} From 376a6c9953e9e71d893e8be8dc36a018eb8929f1 Mon Sep 17 00:00:00 2001 From: Nate McMaster Date: Mon, 5 Nov 2018 13:09:10 -0800 Subject: [PATCH 2/6] Merge branch 'release/2.1' into release/2.2 \n\nCommit migrated from https://github.com/dotnet/extensions/commit/2d152804642f41aaeacc09307bcebb065131e75d --- .../src/Microsoft.Extensions.FileProviders.Embedded.csproj | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/FileProviders/Embedded/src/Microsoft.Extensions.FileProviders.Embedded.csproj b/src/FileProviders/Embedded/src/Microsoft.Extensions.FileProviders.Embedded.csproj index d7ca20b469..ec2c10b569 100644 --- a/src/FileProviders/Embedded/src/Microsoft.Extensions.FileProviders.Embedded.csproj +++ b/src/FileProviders/Embedded/src/Microsoft.Extensions.FileProviders.Embedded.csproj @@ -12,6 +12,12 @@ + + + + + + From ac89e3e9bfc5eec640c9a2fd68416a443ac667c3 Mon Sep 17 00:00:00 2001 From: Nate McMaster Date: Mon, 5 Nov 2018 16:20:37 -0800 Subject: [PATCH 3/6] Merge branch 'release/2.1' into release/2.2 \n\nCommit migrated from https://github.com/dotnet/extensions/commit/d94eb17013c924e4b932b89bbbe729fa2bf1899e --- src/Configuration.KeyPerFile/test/KeyPerFileTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs b/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs index d55387a404..d409c0eab0 100644 --- a/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs +++ b/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs @@ -28,7 +28,7 @@ namespace Microsoft.Extensions.Configuration.KeyPerFile.Test public void ThrowsWhenNotOptionalAndDirectoryDoesntExist() { var e = Assert.Throws(() => new ConfigurationBuilder().AddKeyPerFile("nonexistent", false).Build()); - Assert.Contains("The directory name", e.Message); + Assert.Contains("The path must be absolute.", e.Message); } [Fact] @@ -305,4 +305,4 @@ namespace Microsoft.Extensions.Configuration.KeyPerFile.Test return new MemoryStream(Encoding.UTF8.GetBytes(_contents)); } } -} \ No newline at end of file +} From 88003f2c215bff9ca9cf3039f14de45d14ccf7fc Mon Sep 17 00:00:00 2001 From: Nate McMaster Date: Wed, 14 Nov 2018 08:43:07 -0800 Subject: [PATCH 4/6] Prepare repo to build 2.2.1 * Update dependencies to 2.2.0 rtm * Update branding to 2.2.1 * Update package baselines to 2.2.0 * Add a restore source for 2.2.0 RTM \n\nCommit migrated from https://github.com/dotnet/extensions/commit/913f74d590bcbdd1165d8faa1d99f3f11197aa4d --- src/Testing/src/Microsoft.AspNetCore.Testing.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Testing/src/Microsoft.AspNetCore.Testing.csproj b/src/Testing/src/Microsoft.AspNetCore.Testing.csproj index d9d9008dd2..64e0b3c4e1 100644 --- a/src/Testing/src/Microsoft.AspNetCore.Testing.csproj +++ b/src/Testing/src/Microsoft.AspNetCore.Testing.csproj @@ -8,6 +8,8 @@ aspnetcore false true + + true From 6dc295b5782302ded80a5ae088d99d5868229818 Mon Sep 17 00:00:00 2001 From: Nate McMaster Date: Tue, 20 Nov 2018 20:49:10 -0800 Subject: [PATCH 5/6] Reorganize source code in preparation to move into aspnet/Extensions Prior to reorganization, this source code was found in https://github.com/aspnet/Diagnostics/tree/dotnet/extensions@c802d5ef5fba1ba8dfbcb8c3741af2ba15e9d1aa \n\nCommit migrated from https://github.com/dotnet/extensions/commit/a03861270c35c4c7f44c0914c01cdf24602973b2 --- .../Abstractions/src/HealthCheckContext.cs | 13 + .../src/HealthCheckRegistration.cs | 132 +++++ .../Abstractions/src/HealthCheckResult.cs | 88 +++ .../Abstractions/src/HealthReport.cs | 68 +++ .../Abstractions/src/HealthReportEntry.cs | 59 ++ .../Abstractions/src/HealthStatus.cs | 37 ++ .../Abstractions/src/IHealthCheck.cs | 23 + .../Abstractions/src/IHealthCheckPublisher.cs | 39 ++ ...agnostics.HealthChecks.Abstractions.csproj | 16 + .../Abstractions/src/baseline.netcore.json | 5 + .../src/DefaultHealthCheckService.cs | 304 ++++++++++ .../HealthChecks/src/DelegateHealthCheck.cs | 35 ++ .../HealthCheckServiceCollectionExtensions.cs | 33 ++ .../HealthChecksBuilder.cs | 33 ++ .../HealthChecksBuilderAddCheckExtensions.cs | 191 +++++++ .../HealthChecksBuilderDelegateExtensions.cs | 149 +++++ .../IHealthChecksBuilder.cs | 24 + .../HealthChecks/src/HealthCheckLogScope.cs | 48 ++ .../src/HealthCheckPublisherHostedService.cs | 262 +++++++++ .../src/HealthCheckPublisherOptions.cs | 84 +++ .../HealthChecks/src/HealthCheckService.cs | 61 ++ .../src/HealthCheckServiceOptions.cs | 18 + ...Extensions.Diagnostics.HealthChecks.csproj | 26 + .../src/Properties/AssemblyInfo.cs | 3 + .../HealthChecks/src/baseline.netcore.json | 5 + .../test/DefaultHealthCheckServiceTest.cs | 419 ++++++++++++++ .../HealthChecksBuilderTest.cs | 257 +++++++++ .../ServiceCollectionExtensionsTest.cs | 43 ++ .../HealthCheckPublisherHostedServiceTest.cs | 528 ++++++++++++++++++ .../HealthChecks/test/HealthReportTest.cs | 45 ++ ...ions.Diagnostics.HealthChecks.Tests.csproj | 12 + 31 files changed, 3060 insertions(+) create mode 100644 src/HealthChecks/Abstractions/src/HealthCheckContext.cs create mode 100644 src/HealthChecks/Abstractions/src/HealthCheckRegistration.cs create mode 100644 src/HealthChecks/Abstractions/src/HealthCheckResult.cs create mode 100644 src/HealthChecks/Abstractions/src/HealthReport.cs create mode 100644 src/HealthChecks/Abstractions/src/HealthReportEntry.cs create mode 100644 src/HealthChecks/Abstractions/src/HealthStatus.cs create mode 100644 src/HealthChecks/Abstractions/src/IHealthCheck.cs create mode 100644 src/HealthChecks/Abstractions/src/IHealthCheckPublisher.cs create mode 100644 src/HealthChecks/Abstractions/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.csproj create mode 100644 src/HealthChecks/Abstractions/src/baseline.netcore.json create mode 100644 src/HealthChecks/HealthChecks/src/DefaultHealthCheckService.cs create mode 100644 src/HealthChecks/HealthChecks/src/DelegateHealthCheck.cs create mode 100644 src/HealthChecks/HealthChecks/src/DependencyInjection/HealthCheckServiceCollectionExtensions.cs create mode 100644 src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilder.cs create mode 100644 src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilderAddCheckExtensions.cs create mode 100644 src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilderDelegateExtensions.cs create mode 100644 src/HealthChecks/HealthChecks/src/DependencyInjection/IHealthChecksBuilder.cs create mode 100644 src/HealthChecks/HealthChecks/src/HealthCheckLogScope.cs create mode 100644 src/HealthChecks/HealthChecks/src/HealthCheckPublisherHostedService.cs create mode 100644 src/HealthChecks/HealthChecks/src/HealthCheckPublisherOptions.cs create mode 100644 src/HealthChecks/HealthChecks/src/HealthCheckService.cs create mode 100644 src/HealthChecks/HealthChecks/src/HealthCheckServiceOptions.cs create mode 100644 src/HealthChecks/HealthChecks/src/Microsoft.Extensions.Diagnostics.HealthChecks.csproj create mode 100644 src/HealthChecks/HealthChecks/src/Properties/AssemblyInfo.cs create mode 100644 src/HealthChecks/HealthChecks/src/baseline.netcore.json create mode 100644 src/HealthChecks/HealthChecks/test/DefaultHealthCheckServiceTest.cs create mode 100644 src/HealthChecks/HealthChecks/test/DependencyInjection/HealthChecksBuilderTest.cs create mode 100644 src/HealthChecks/HealthChecks/test/DependencyInjection/ServiceCollectionExtensionsTest.cs create mode 100644 src/HealthChecks/HealthChecks/test/HealthCheckPublisherHostedServiceTest.cs create mode 100644 src/HealthChecks/HealthChecks/test/HealthReportTest.cs create mode 100644 src/HealthChecks/HealthChecks/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests.csproj diff --git a/src/HealthChecks/Abstractions/src/HealthCheckContext.cs b/src/HealthChecks/Abstractions/src/HealthCheckContext.cs new file mode 100644 index 0000000000..027451c0d2 --- /dev/null +++ b/src/HealthChecks/Abstractions/src/HealthCheckContext.cs @@ -0,0 +1,13 @@ +// 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. + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + public sealed class HealthCheckContext + { + /// + /// Gets or sets the of the currently executing . + /// + public HealthCheckRegistration Registration { get; set; } + } +} diff --git a/src/HealthChecks/Abstractions/src/HealthCheckRegistration.cs b/src/HealthChecks/Abstractions/src/HealthCheckRegistration.cs new file mode 100644 index 0000000000..9291c38846 --- /dev/null +++ b/src/HealthChecks/Abstractions/src/HealthCheckRegistration.cs @@ -0,0 +1,132 @@ +// 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.Collections.Generic; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represent the registration information associated with an implementation. + /// + /// + /// + /// The health check registration is provided as a separate object so that application developers can customize + /// how health check implementations are configured. + /// + /// + /// The registration is provided to an implementation during execution through + /// . This allows a health check implementation to access named + /// options or perform other operations based on the registered name. + /// + /// + public sealed class HealthCheckRegistration + { + private Func _factory; + private string _name; + + /// + /// Creates a new for an existing instance. + /// + /// The health check name. + /// The instance. + /// + /// The that should be reported upon failure of the health check. If the provided value + /// is null, then will be reported. + /// + /// A list of tags that can be used for filtering health checks. + public HealthCheckRegistration(string name, IHealthCheck instance, HealthStatus? failureStatus, IEnumerable tags) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (instance == null) + { + throw new ArgumentNullException(nameof(instance)); + } + + Name = name; + FailureStatus = failureStatus ?? HealthStatus.Unhealthy; + Tags = new HashSet(tags ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); + Factory = (_) => instance; + } + + /// + /// Creates a new for an existing instance. + /// + /// The health check name. + /// A delegate used to create the instance. + /// + /// The that should be reported when the health check reports a failure. If the provided value + /// is null, then will be reported. + /// + /// A list of tags that can be used for filtering health checks. + public HealthCheckRegistration( + string name, + Func factory, + HealthStatus? failureStatus, + IEnumerable tags) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + Name = name; + FailureStatus = failureStatus ?? HealthStatus.Unhealthy; + Tags = new HashSet(tags ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); + Factory = factory; + } + + /// + /// Gets or sets a delegate used to create the instance. + /// + public Func Factory + { + get => _factory; + set + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + _factory = value; + } + } + + /// + /// Gets or sets the that should be reported upon failure of the health check. + /// + public HealthStatus FailureStatus { get; set; } + + /// + /// Gets or sets the health check name. + /// + public string Name + { + get => _name; + set + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + _name = value; + } + } + + /// + /// Gets a list of tags that can be used for filtering health checks. + /// + public ISet Tags { get; } + } +} diff --git a/src/HealthChecks/Abstractions/src/HealthCheckResult.cs b/src/HealthChecks/Abstractions/src/HealthCheckResult.cs new file mode 100644 index 0000000000..e01cb5aceb --- /dev/null +++ b/src/HealthChecks/Abstractions/src/HealthCheckResult.cs @@ -0,0 +1,88 @@ +// 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.Collections.Generic; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represents the result of a health check. + /// + public struct HealthCheckResult + { + private static readonly IReadOnlyDictionary _emptyReadOnlyDictionary = new Dictionary(); + + /// + /// Creates a new with the specified values for , + /// , , and . + /// + /// A value indicating the status of the component that was checked. + /// A human-readable description of the status of the component that was checked. + /// An representing the exception that was thrown when checking for status (if any). + /// Additional key-value pairs describing the health of the component. + public HealthCheckResult(HealthStatus status, string description = null, Exception exception = null, IReadOnlyDictionary data = null) + { + Status = status; + Description = description; + Exception = exception; + Data = data ?? _emptyReadOnlyDictionary; + } + + /// + /// Gets additional key-value pairs describing the health of the component. + /// + public IReadOnlyDictionary Data { get; } + + /// + /// Gets a human-readable description of the status of the component that was checked. + /// + public string Description { get; } + + /// + /// Gets an representing the exception that was thrown when checking for status (if any). + /// + public Exception Exception { get; } + + /// + /// Gets a value indicating the status of the component that was checked. + /// + public HealthStatus Status { get; } + + /// + /// Creates a representing a healthy component. + /// + /// A human-readable description of the status of the component that was checked. Optional. + /// Additional key-value pairs describing the health of the component. Optional. + /// A representing a healthy component. + public static HealthCheckResult Healthy(string description = null, IReadOnlyDictionary data = null) + { + return new HealthCheckResult(status: HealthStatus.Healthy, description, exception: null, data); + } + + + /// + /// Creates a representing a degraded component. + /// + /// A human-readable description of the status of the component that was checked. Optional. + /// An representing the exception that was thrown when checking for status. Optional. + /// Additional key-value pairs describing the health of the component. Optional. + /// A representing a degraged component. + public static HealthCheckResult Degraded(string description = null, Exception exception = null, IReadOnlyDictionary data = null) + { + return new HealthCheckResult(status: HealthStatus.Degraded, description, exception: null, data); + } + + /// + /// Creates a representing an unhealthy component. + /// + /// A human-readable description of the status of the component that was checked. Optional. + /// An representing the exception that was thrown when checking for status. Optional. + /// Additional key-value pairs describing the health of the component. Optional. + /// A representing an unhealthy component. + public static HealthCheckResult Unhealthy(string description = null, Exception exception = null, IReadOnlyDictionary data = null) + { + return new HealthCheckResult(status: HealthStatus.Unhealthy, description, exception, data); + } + } +} diff --git a/src/HealthChecks/Abstractions/src/HealthReport.cs b/src/HealthChecks/Abstractions/src/HealthReport.cs new file mode 100644 index 0000000000..91ed798811 --- /dev/null +++ b/src/HealthChecks/Abstractions/src/HealthReport.cs @@ -0,0 +1,68 @@ +// 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.Collections.Generic; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represents the result of executing a group of instances. + /// + public sealed class HealthReport + { + /// + /// Create a new from the specified results. + /// + /// A containing the results from each health check. + /// A value indicating the time the health check service took to execute. + public HealthReport(IReadOnlyDictionary entries, TimeSpan totalDuration) + { + Entries = entries; + Status = CalculateAggregateStatus(entries.Values); + TotalDuration = totalDuration; + } + + /// + /// A containing the results from each health check. + /// + /// + /// The keys in this dictionary map the name of each executed health check to a for the + /// result data retruned from the corresponding health check. + /// + public IReadOnlyDictionary Entries { get; } + + /// + /// Gets a representing the aggregate status of all the health checks. The value of + /// will be the most servere status reported by a health check. If no checks were executed, the value is always . + /// + public HealthStatus Status { get; } + + /// + /// Gets the time the health check service took to execute. + /// + public TimeSpan TotalDuration { get; } + + private HealthStatus CalculateAggregateStatus(IEnumerable entries) + { + // This is basically a Min() check, but we know the possible range, so we don't need to walk the whole list + var currentValue = HealthStatus.Healthy; + foreach (var entry in entries) + { + if (currentValue > entry.Status) + { + currentValue = entry.Status; + } + + if (currentValue == HealthStatus.Unhealthy) + { + // Game over, man! Game over! + // (We hit the worst possible status, so there's no need to keep iterating) + return currentValue; + } + } + + return currentValue; + } + } +} diff --git a/src/HealthChecks/Abstractions/src/HealthReportEntry.cs b/src/HealthChecks/Abstractions/src/HealthReportEntry.cs new file mode 100644 index 0000000000..6e7d6c6b8e --- /dev/null +++ b/src/HealthChecks/Abstractions/src/HealthReportEntry.cs @@ -0,0 +1,59 @@ +// 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.Collections.Generic; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represents an entry in a . Corresponds to the result of a single . + /// + public struct HealthReportEntry + { + private static readonly IReadOnlyDictionary _emptyReadOnlyDictionary = new Dictionary(); + + /// + /// Creates a new with the specified values for , , + /// , and . + /// + /// A value indicating the health status of the component that was checked. + /// A human-readable description of the status of the component that was checked. + /// A value indicating the health execution duration. + /// An representing the exception that was thrown when checking for status (if any). + /// Additional key-value pairs describing the health of the component. + public HealthReportEntry(HealthStatus status, string description, TimeSpan duration, Exception exception, IReadOnlyDictionary data) + { + Status = status; + Description = description; + Duration = duration; + Exception = exception; + Data = data ?? _emptyReadOnlyDictionary; + } + + /// + /// Gets additional key-value pairs describing the health of the component. + /// + public IReadOnlyDictionary Data { get; } + + /// + /// Gets a human-readable description of the status of the component that was checked. + /// + public string Description { get; } + + /// + /// Gets the health check execution duration. + /// + public TimeSpan Duration { get; } + + /// + /// Gets an representing the exception that was thrown when checking for status (if any). + /// + public Exception Exception { get; } + + /// + /// Gets the health status of the component that was checked. + /// + public HealthStatus Status { get; } + } +} diff --git a/src/HealthChecks/Abstractions/src/HealthStatus.cs b/src/HealthChecks/Abstractions/src/HealthStatus.cs new file mode 100644 index 0000000000..61b76d54fa --- /dev/null +++ b/src/HealthChecks/Abstractions/src/HealthStatus.cs @@ -0,0 +1,37 @@ +// 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. + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represents the reported status of a health check result. + /// + /// + /// + /// A status of should be considered the default value for a failing health check. Application + /// developers may configure a health check to report a different status as desired. + /// + /// + /// The values of this enum or ordered from least healthy to most healthy. So is + /// greater than but less than . + /// + /// + public enum HealthStatus + { + /// + /// Indicates that the health check determined that the component was unhealthy, or an unhandled + /// exception was thrown while executing the health check. + /// + Unhealthy = 0, + + /// + /// Indicates that the health check determined that the component was in a degraded state. + /// + Degraded = 1, + + /// + /// Indicates that the health check determined that the component was healthy. + /// + Healthy = 2, + } +} diff --git a/src/HealthChecks/Abstractions/src/IHealthCheck.cs b/src/HealthChecks/Abstractions/src/IHealthCheck.cs new file mode 100644 index 0000000000..1b69953b67 --- /dev/null +++ b/src/HealthChecks/Abstractions/src/IHealthCheck.cs @@ -0,0 +1,23 @@ +// 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; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represents a health check, which can be used to check the status of a component in the application, such as a backend service, database or some internal + /// state. + /// + public interface IHealthCheck + { + /// + /// Runs the health check, returning the status of the component being checked. + /// + /// A context object associated with the current execution. + /// A that can be used to cancel the health check. + /// A that completes when the health check has finished, yielding the status of the component being checked. + Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default); + } +} diff --git a/src/HealthChecks/Abstractions/src/IHealthCheckPublisher.cs b/src/HealthChecks/Abstractions/src/IHealthCheckPublisher.cs new file mode 100644 index 0000000000..f1809c4bb8 --- /dev/null +++ b/src/HealthChecks/Abstractions/src/IHealthCheckPublisher.cs @@ -0,0 +1,39 @@ +// 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; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represents a publisher of information. + /// + /// + /// + /// The default health checks implementation provided an IHostedService implementation that can + /// be used to execute health checks at regular intervals and provide the resulting + /// data to all registered instances. + /// + /// + /// To provide an implementation, register an instance or type as a singleton + /// service in the dependency injection container. + /// + /// + /// instances are provided with a after executing + /// health checks in a background thread. The use of depend on hosting in + /// an application using IWebHost or generic host (IHost). Execution of + /// instance is not related to execution of health checks via a middleware. + /// + /// + public interface IHealthCheckPublisher + { + /// + /// Publishes the provided . + /// + /// The . The result of executing a set of health checks. + /// The . + /// A which will complete when publishing is complete. + Task PublishAsync(HealthReport report, CancellationToken cancellationToken); + } +} diff --git a/src/HealthChecks/Abstractions/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.csproj b/src/HealthChecks/Abstractions/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.csproj new file mode 100644 index 0000000000..b95d66f7b3 --- /dev/null +++ b/src/HealthChecks/Abstractions/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.csproj @@ -0,0 +1,16 @@ + + + + Abstractions for defining health checks in .NET applications + +Commonly Used Types +Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck + + Microsoft.Extensions.Diagnostics.HealthChecks + netstandard2.0 + $(NoWarn);CS1591 + true + diagnostics;healthchecks + + + diff --git a/src/HealthChecks/Abstractions/src/baseline.netcore.json b/src/HealthChecks/Abstractions/src/baseline.netcore.json new file mode 100644 index 0000000000..871db4c089 --- /dev/null +++ b/src/HealthChecks/Abstractions/src/baseline.netcore.json @@ -0,0 +1,5 @@ +{ + "AssemblyIdentity": "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions, Version=2.1.1.0, Culture=neutral, PublicKeyToken=adb9793829ddae60", + "Types": [ + ] +} \ No newline at end of file diff --git a/src/HealthChecks/HealthChecks/src/DefaultHealthCheckService.cs b/src/HealthChecks/HealthChecks/src/DefaultHealthCheckService.cs new file mode 100644 index 0000000000..d5d71d9cb4 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/DefaultHealthCheckService.cs @@ -0,0 +1,304 @@ +// 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.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + internal class DefaultHealthCheckService : HealthCheckService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly IOptions _options; + private readonly ILogger _logger; + + public DefaultHealthCheckService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + // We're specifically going out of our way to do this at startup time. We want to make sure you + // get any kind of health-check related error as early as possible. Waiting until someone + // actually tries to **run** health checks would be real baaaaad. + ValidateRegistrations(_options.Value.Registrations); + } + public override async Task CheckHealthAsync( + Func predicate, + CancellationToken cancellationToken = default) + { + var registrations = _options.Value.Registrations; + + using (var scope = _scopeFactory.CreateScope()) + { + var context = new HealthCheckContext(); + var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var totalTime = ValueStopwatch.StartNew(); + Log.HealthCheckProcessingBegin(_logger); + + foreach (var registration in registrations) + { + if (predicate != null && !predicate(registration)) + { + continue; + } + + cancellationToken.ThrowIfCancellationRequested(); + + var healthCheck = registration.Factory(scope.ServiceProvider); + + // If the health check does things like make Database queries using EF or backend HTTP calls, + // it may be valuable to know that logs it generates are part of a health check. So we start a scope. + using (_logger.BeginScope(new HealthCheckLogScope(registration.Name))) + { + var stopwatch = ValueStopwatch.StartNew(); + context.Registration = registration; + + Log.HealthCheckBegin(_logger, registration); + + HealthReportEntry entry; + try + { + var result = await healthCheck.CheckHealthAsync(context, cancellationToken); + var duration = stopwatch.GetElapsedTime(); + + entry = new HealthReportEntry( + status: result.Status, + description: result.Description, + duration: duration, + exception: result.Exception, + data: result.Data); + + Log.HealthCheckEnd(_logger, registration, entry, duration); + Log.HealthCheckData(_logger, registration, entry); + } + + // Allow cancellation to propagate. + catch (Exception ex) when (ex as OperationCanceledException == null) + { + var duration = stopwatch.GetElapsedTime(); + entry = new HealthReportEntry( + status: HealthStatus.Unhealthy, + description: ex.Message, + duration: duration, + exception: ex, + data: null); + + Log.HealthCheckError(_logger, registration, ex, duration); + } + + entries[registration.Name] = entry; + } + } + + var totalElapsedTime = totalTime.GetElapsedTime(); + var report = new HealthReport(entries, totalElapsedTime); + Log.HealthCheckProcessingEnd(_logger, report.Status, totalElapsedTime); + return report; + } + } + + private static void ValidateRegistrations(IEnumerable registrations) + { + // Scan the list for duplicate names to provide a better error if there are duplicates. + var duplicateNames = registrations + .GroupBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + + if (duplicateNames.Count > 0) + { + throw new ArgumentException($"Duplicate health checks were registered with the name(s): {string.Join(", ", duplicateNames)}", nameof(registrations)); + } + } + + internal static class EventIds + { + public static readonly EventId HealthCheckProcessingBegin = new EventId(100, "HealthCheckProcessingBegin"); + public static readonly EventId HealthCheckProcessingEnd = new EventId(101, "HealthCheckProcessingEnd"); + + public static readonly EventId HealthCheckBegin = new EventId(102, "HealthCheckBegin"); + public static readonly EventId HealthCheckEnd = new EventId(103, "HealthCheckEnd"); + public static readonly EventId HealthCheckError = new EventId(104, "HealthCheckError"); + public static readonly EventId HealthCheckData = new EventId(105, "HealthCheckData"); + } + + private static class Log + { + private static readonly Action _healthCheckProcessingBegin = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckProcessingBegin, + "Running health checks"); + + private static readonly Action _healthCheckProcessingEnd = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckProcessingEnd, + "Health check processing completed after {ElapsedMilliseconds}ms with combined status {HealthStatus}"); + + private static readonly Action _healthCheckBegin = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckBegin, + "Running health check {HealthCheckName}"); + + // These are separate so they can have different log levels + private static readonly string HealthCheckEndText = "Health check {HealthCheckName} completed after {ElapsedMilliseconds}ms with status {HealthStatus} and '{HealthCheckDescription}'"; + + private static readonly Action _healthCheckEndHealthy = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckEnd, + HealthCheckEndText); + + private static readonly Action _healthCheckEndDegraded = LoggerMessage.Define( + LogLevel.Warning, + EventIds.HealthCheckEnd, + HealthCheckEndText); + + private static readonly Action _healthCheckEndUnhealthy = LoggerMessage.Define( + LogLevel.Error, + EventIds.HealthCheckEnd, + HealthCheckEndText); + + private static readonly Action _healthCheckEndFailed = LoggerMessage.Define( + LogLevel.Error, + EventIds.HealthCheckEnd, + HealthCheckEndText); + + private static readonly Action _healthCheckError = LoggerMessage.Define( + LogLevel.Error, + EventIds.HealthCheckError, + "Health check {HealthCheckName} threw an unhandled exception after {ElapsedMilliseconds}ms"); + + public static void HealthCheckProcessingBegin(ILogger logger) + { + _healthCheckProcessingBegin(logger, null); + } + + public static void HealthCheckProcessingEnd(ILogger logger, HealthStatus status, TimeSpan duration) + { + _healthCheckProcessingEnd(logger, duration.TotalMilliseconds, status, null); + } + + public static void HealthCheckBegin(ILogger logger, HealthCheckRegistration registration) + { + _healthCheckBegin(logger, registration.Name, null); + } + + public static void HealthCheckEnd(ILogger logger, HealthCheckRegistration registration, HealthReportEntry entry, TimeSpan duration) + { + switch (entry.Status) + { + case HealthStatus.Healthy: + _healthCheckEndHealthy(logger, registration.Name, duration.TotalMilliseconds, entry.Status, entry.Description, null); + break; + + case HealthStatus.Degraded: + _healthCheckEndDegraded(logger, registration.Name, duration.TotalMilliseconds, entry.Status, entry.Description, null); + break; + + case HealthStatus.Unhealthy: + _healthCheckEndUnhealthy(logger, registration.Name, duration.TotalMilliseconds, entry.Status, entry.Description, null); + break; + } + } + + public static void HealthCheckError(ILogger logger, HealthCheckRegistration registration, Exception exception, TimeSpan duration) + { + _healthCheckError(logger, registration.Name, duration.TotalMilliseconds, exception); + } + + public static void HealthCheckData(ILogger logger, HealthCheckRegistration registration, HealthReportEntry entry) + { + if (entry.Data.Count > 0 && logger.IsEnabled(LogLevel.Debug)) + { + logger.Log( + LogLevel.Debug, + EventIds.HealthCheckData, + new HealthCheckDataLogValue(registration.Name, entry.Data), + null, + (state, ex) => state.ToString()); + } + } + } + + internal class HealthCheckDataLogValue : IReadOnlyList> + { + private readonly string _name; + private readonly List> _values; + + private string _formatted; + + public HealthCheckDataLogValue(string name, IReadOnlyDictionary values) + { + _name = name; + _values = values.ToList(); + + // We add the name as a kvp so that you can filter by health check name in the logs. + // This is the same parameter name used in the other logs. + _values.Add(new KeyValuePair("HealthCheckName", name)); + } + + public KeyValuePair this[int index] + { + get + { + if (index < 0 || index >= Count) + { + throw new IndexOutOfRangeException(nameof(index)); + } + + return _values[index]; + } + } + + public int Count => _values.Count; + + public IEnumerator> GetEnumerator() + { + return _values.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return _values.GetEnumerator(); + } + + public override string ToString() + { + if (_formatted == null) + { + var builder = new StringBuilder(); + builder.AppendLine($"Health check data for {_name}:"); + + var values = _values; + for (var i = 0; i < values.Count; i++) + { + var kvp = values[i]; + builder.Append(" "); + builder.Append(kvp.Key); + builder.Append(": "); + + builder.AppendLine(kvp.Value?.ToString()); + } + + _formatted = builder.ToString(); + } + + return _formatted; + } + } + } +} diff --git a/src/HealthChecks/HealthChecks/src/DelegateHealthCheck.cs b/src/HealthChecks/HealthChecks/src/DelegateHealthCheck.cs new file mode 100644 index 0000000000..94069fd7d1 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/DelegateHealthCheck.cs @@ -0,0 +1,35 @@ +// 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.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// A simple implementation of which uses a provided delegate to + /// implement the check. + /// + internal sealed class DelegateHealthCheck : IHealthCheck + { + private readonly Func> _check; + + /// + /// Create an instance of from the specified delegate. + /// + /// A delegate which provides the code to execute when the health check is run. + public DelegateHealthCheck(Func> check) + { + _check = check ?? throw new ArgumentNullException(nameof(check)); + } + + /// + /// Runs the health check, returning the status of the component being checked. + /// + /// A context object associated with the current execution. + /// A that can be used to cancel the health check. + /// A that completes when the health check has finished, yielding the status of the component being checked. + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) => _check(cancellationToken); + } +} diff --git a/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthCheckServiceCollectionExtensions.cs b/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthCheckServiceCollectionExtensions.cs new file mode 100644 index 0000000000..d6df03d2ae --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthCheckServiceCollectionExtensions.cs @@ -0,0 +1,33 @@ +// 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 Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Provides extension methods for registering in an . + /// + public static class HealthCheckServiceCollectionExtensions + { + /// + /// Adds the to the container, using the provided delegate to register + /// health checks. + /// + /// + /// This operation is idempotent - multiple invocations will still only result in a single + /// instance in the . It can be invoked + /// multiple times in order to get access to the in multiple places. + /// + /// The to add the to. + /// An instance of from which health checks can be registered. + public static IHealthChecksBuilder AddHealthChecks(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + return new HealthChecksBuilder(services); + } + } +} diff --git a/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilder.cs b/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilder.cs new file mode 100644 index 0000000000..231dd51717 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilder.cs @@ -0,0 +1,33 @@ +// 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 Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.DependencyInjection +{ + internal class HealthChecksBuilder : IHealthChecksBuilder + { + public HealthChecksBuilder(IServiceCollection services) + { + Services = services; + } + + public IServiceCollection Services { get; } + + public IHealthChecksBuilder Add(HealthCheckRegistration registration) + { + if (registration == null) + { + throw new ArgumentNullException(nameof(registration)); + } + + Services.Configure(options => + { + options.Registrations.Add(registration); + }); + + return this; + } + } +} diff --git a/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilderAddCheckExtensions.cs b/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilderAddCheckExtensions.cs new file mode 100644 index 0000000000..9508889054 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilderAddCheckExtensions.cs @@ -0,0 +1,191 @@ +// 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.Collections.Generic; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Provides basic extension methods for registering instances in an . + /// + public static class HealthChecksBuilderAddCheckExtensions + { + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The . + /// The name of the health check. + /// An instance. + /// + /// The that should be reported when the health check reports a failure. If the provided value + /// is null, then will be reported. + /// + /// A list of tags that can be used to filter health checks. + /// The . + public static IHealthChecksBuilder AddCheck( + this IHealthChecksBuilder builder, + string name, + IHealthCheck instance, + HealthStatus? failureStatus = null, + IEnumerable tags = null) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (instance == null) + { + throw new ArgumentNullException(nameof(instance)); + } + + return builder.Add(new HealthCheckRegistration(name, instance, failureStatus, tags)); + } + + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The health check implementation type. + /// The . + /// The name of the health check. + /// + /// The that should be reported when the health check reports a failure. If the provided value + /// is null, then will be reported. + /// + /// A list of tags that can be used to filter health checks. + /// The . + /// + /// This method will use to create the health check + /// instance when needed. If a service of type is registred in the dependency injection container + /// with any liftime it will be used. Otherwise an instance of type will be constructed with + /// access to services from the dependency injection container. + /// + public static IHealthChecksBuilder AddCheck( + this IHealthChecksBuilder builder, + string name, + HealthStatus? failureStatus = null, + IEnumerable tags = null) where T : class, IHealthCheck + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + return builder.Add(new HealthCheckRegistration(name, s => ActivatorUtilities.GetServiceOrCreateInstance(s), failureStatus, tags)); + } + + // NOTE: AddTypeActivatedCheck has overloads rather than default parameters values, because default parameter values don't + // play super well with params. + + /// + /// Adds a new type activated health check with the specified name and implementation. + /// + /// The health check implementation type. + /// The . + /// The name of the health check. + /// Additional arguments to provide to the constructor. + /// The . + /// + /// This method will use to create the health check + /// instance when needed. Additional arguments can be provided to the constructor via . + /// + public static IHealthChecksBuilder AddTypeActivatedCheck(this IHealthChecksBuilder builder, string name, params object[] args) where T : class, IHealthCheck + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + return AddTypeActivatedCheck(builder, name, failureStatus: null, tags: null); + } + + /// + /// Adds a new type activated health check with the specified name and implementation. + /// + /// The health check implementation type. + /// The . + /// The name of the health check. + /// + /// The that should be reported when the health check reports a failure. If the provided value + /// is null, then will be reported. + /// + /// Additional arguments to provide to the constructor. + /// The . + /// + /// This method will use to create the health check + /// instance when needed. Additional arguments can be provided to the constructor via . + /// + public static IHealthChecksBuilder AddTypeActivatedCheck( + this IHealthChecksBuilder builder, + string name, + HealthStatus? failureStatus, + params object[] args) where T : class, IHealthCheck + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + return AddTypeActivatedCheck(builder, name, failureStatus, tags: null); + } + + /// + /// Adds a new type activated health check with the specified name and implementation. + /// + /// The health check implementation type. + /// The . + /// The name of the health check. + /// + /// The that should be reported when the health check reports a failure. If the provided value + /// is null, then will be reported. + /// + /// A list of tags that can be used to filter health checks. + /// Additional arguments to provide to the constructor. + /// The . + /// + /// This method will use to create the health check + /// instance when needed. Additional arguments can be provided to the constructor via . + /// + public static IHealthChecksBuilder AddTypeActivatedCheck( + this IHealthChecksBuilder builder, + string name, + HealthStatus? failureStatus, + IEnumerable tags, + params object[] args) where T : class, IHealthCheck + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + return builder.Add(new HealthCheckRegistration(name, s => ActivatorUtilities.CreateInstance(s, args), failureStatus, tags)); + } + } +} diff --git a/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilderDelegateExtensions.cs b/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilderDelegateExtensions.cs new file mode 100644 index 0000000000..d7dfdd90ae --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilderDelegateExtensions.cs @@ -0,0 +1,149 @@ +// 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.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Provides extension methods for registering delegates with the . + /// + public static class HealthChecksBuilderDelegateExtensions + { + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The . + /// The name of the health check. + /// A list of tags that can be used to filter health checks. + /// A delegate that provides the health check implementation. + /// The . + public static IHealthChecksBuilder AddCheck( + this IHealthChecksBuilder builder, + string name, + Func check, + IEnumerable tags = null) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (check == null) + { + throw new ArgumentNullException(nameof(check)); + } + + var instance = new DelegateHealthCheck((ct) => Task.FromResult(check())); + return builder.Add(new HealthCheckRegistration(name, instance, failureStatus: null, tags)); + } + + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The . + /// The name of the health check. + /// A list of tags that can be used to filter health checks. + /// A delegate that provides the health check implementation. + /// The . + public static IHealthChecksBuilder AddCheck( + this IHealthChecksBuilder builder, + string name, + Func check, + IEnumerable tags = null) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (check == null) + { + throw new ArgumentNullException(nameof(check)); + } + + var instance = new DelegateHealthCheck((ct) => Task.FromResult(check(ct))); + return builder.Add(new HealthCheckRegistration(name, instance, failureStatus: null, tags)); + } + + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The . + /// The name of the health check. + /// A list of tags that can be used to filter health checks. + /// A delegate that provides the health check implementation. + /// The . + public static IHealthChecksBuilder AddAsyncCheck( + this IHealthChecksBuilder builder, + string name, + Func> check, + IEnumerable tags = null) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (check == null) + { + throw new ArgumentNullException(nameof(check)); + } + + var instance = new DelegateHealthCheck((ct) => check()); + return builder.Add(new HealthCheckRegistration(name, instance, failureStatus: null, tags)); + } + + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The . + /// The name of the health check. + /// A list of tags that can be used to filter health checks. + /// A delegate that provides the health check implementation. + /// The . + public static IHealthChecksBuilder AddAsyncCheck( + this IHealthChecksBuilder builder, + string name, + Func> check, + IEnumerable tags = null) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (check == null) + { + throw new ArgumentNullException(nameof(check)); + } + + var instance = new DelegateHealthCheck((ct) => check(ct)); + return builder.Add(new HealthCheckRegistration(name, instance, failureStatus: null, tags)); + } + } +} diff --git a/src/HealthChecks/HealthChecks/src/DependencyInjection/IHealthChecksBuilder.cs b/src/HealthChecks/HealthChecks/src/DependencyInjection/IHealthChecksBuilder.cs new file mode 100644 index 0000000000..eb78293f87 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/DependencyInjection/IHealthChecksBuilder.cs @@ -0,0 +1,24 @@ +// 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 Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// A builder used to register health checks. + /// + public interface IHealthChecksBuilder + { + /// + /// Adds a for a health check. + /// + /// The . + IHealthChecksBuilder Add(HealthCheckRegistration registration); + + /// + /// Gets the into which instances should be registered. + /// + IServiceCollection Services { get; } + } +} diff --git a/src/HealthChecks/HealthChecks/src/HealthCheckLogScope.cs b/src/HealthChecks/HealthChecks/src/HealthCheckLogScope.cs new file mode 100644 index 0000000000..c7ef3ff5bd --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/HealthCheckLogScope.cs @@ -0,0 +1,48 @@ +// 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.Collections; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + internal class HealthCheckLogScope : IReadOnlyList> + { + public string HealthCheckName { get; } + + int IReadOnlyCollection>.Count { get; } = 1; + + KeyValuePair IReadOnlyList>.this[int index] + { + get + { + if (index == 0) + { + return new KeyValuePair(nameof(HealthCheckName), HealthCheckName); + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + } + + /// + /// Creates a new instance of with the provided name. + /// + /// The name of the health check being executed. + public HealthCheckLogScope(string healthCheckName) + { + HealthCheckName = healthCheckName; + } + + IEnumerator> IEnumerable>.GetEnumerator() + { + yield return new KeyValuePair(nameof(HealthCheckName), HealthCheckName); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable>)this).GetEnumerator(); + } + } +} diff --git a/src/HealthChecks/HealthChecks/src/HealthCheckPublisherHostedService.cs b/src/HealthChecks/HealthChecks/src/HealthCheckPublisherHostedService.cs new file mode 100644 index 0000000000..d124ffa2e3 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/HealthCheckPublisherHostedService.cs @@ -0,0 +1,262 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + internal sealed class HealthCheckPublisherHostedService : IHostedService + { + private readonly HealthCheckService _healthCheckService; + private readonly IOptions _options; + private readonly ILogger _logger; + private readonly IHealthCheckPublisher[] _publishers; + + private CancellationTokenSource _stopping; + private Timer _timer; + + public HealthCheckPublisherHostedService( + HealthCheckService healthCheckService, + IOptions options, + ILogger logger, + IEnumerable publishers) + { + if (healthCheckService == null) + { + throw new ArgumentNullException(nameof(healthCheckService)); + } + + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (logger == null) + { + throw new ArgumentNullException(nameof(logger)); + } + + if (publishers == null) + { + throw new ArgumentNullException(nameof(publishers)); + } + + _healthCheckService = healthCheckService; + _options = options; + _logger = logger; + _publishers = publishers.ToArray(); + + _stopping = new CancellationTokenSource(); + } + + internal bool IsStopping => _stopping.IsCancellationRequested; + + internal bool IsTimerRunning => _timer != null; + + public Task StartAsync(CancellationToken cancellationToken = default) + { + if (_publishers.Length == 0) + { + return Task.CompletedTask; + } + + // IMPORTANT - make sure this is the last thing that happens in this method. The timer can + // fire before other code runs. + _timer = NonCapturingTimer.Create(Timer_Tick, null, dueTime: _options.Value.Delay, period: _options.Value.Period); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken = default) + { + try + { + _stopping.Cancel(); + } + catch + { + // Ignore exceptions thrown as a result of a cancellation. + } + + if (_publishers.Length == 0) + { + return Task.CompletedTask; + } + + _timer?.Dispose(); + _timer = null; + + + return Task.CompletedTask; + } + + // Yes, async void. We need to be async. We need to be void. We handle the exceptions in RunAsync + private async void Timer_Tick(object state) + { + await RunAsync(); + } + + // Internal for testing + internal async Task RunAsync() + { + var duration = ValueStopwatch.StartNew(); + Logger.HealthCheckPublisherProcessingBegin(_logger); + + CancellationTokenSource cancellation = null; + try + { + var timeout = _options.Value.Timeout; + + cancellation = CancellationTokenSource.CreateLinkedTokenSource(_stopping.Token); + cancellation.CancelAfter(timeout); + + await RunAsyncCore(cancellation.Token); + + Logger.HealthCheckPublisherProcessingEnd(_logger, duration.GetElapsedTime()); + } + catch (OperationCanceledException) when (IsStopping) + { + // This is a cancellation - if the app is shutting down we want to ignore it. Otherwise, it's + // a timeout and we want to log it. + } + catch (Exception ex) + { + // This is an error, publishing failed. + Logger.HealthCheckPublisherProcessingEnd(_logger, duration.GetElapsedTime(), ex); + } + finally + { + cancellation.Dispose(); + } + } + + private async Task RunAsyncCore(CancellationToken cancellationToken) + { + // Forcibly yield - we want to unblock the timer thread. + await Task.Yield(); + + // The health checks service does it's own logging, and doesn't throw exceptions. + var report = await _healthCheckService.CheckHealthAsync(_options.Value.Predicate, cancellationToken); + + var publishers = _publishers; + var tasks = new Task[publishers.Length]; + for (var i = 0; i < publishers.Length; i++) + { + tasks[i] = RunPublisherAsync(publishers[i], report, cancellationToken); + } + + await Task.WhenAll(tasks); + } + + private async Task RunPublisherAsync(IHealthCheckPublisher publisher, HealthReport report, CancellationToken cancellationToken) + { + var duration = ValueStopwatch.StartNew(); + + try + { + Logger.HealthCheckPublisherBegin(_logger, publisher); + + await publisher.PublishAsync(report, cancellationToken); + Logger.HealthCheckPublisherEnd(_logger, publisher, duration.GetElapsedTime()); + } + catch (OperationCanceledException) when (IsStopping) + { + // This is a cancellation - if the app is shutting down we want to ignore it. Otherwise, it's + // a timeout and we want to log it. + } + catch (OperationCanceledException ocex) + { + Logger.HealthCheckPublisherTimeout(_logger, publisher, duration.GetElapsedTime()); + throw ocex; + } + catch (Exception ex) + { + Logger.HealthCheckPublisherError(_logger, publisher, duration.GetElapsedTime(), ex); + throw ex; + } + } + + internal static class EventIds + { + public static readonly EventId HealthCheckPublisherProcessingBegin = new EventId(100, "HealthCheckPublisherProcessingBegin"); + public static readonly EventId HealthCheckPublisherProcessingEnd = new EventId(101, "HealthCheckPublisherProcessingEnd"); + public static readonly EventId HealthCheckPublisherProcessingError = new EventId(101, "HealthCheckPublisherProcessingError"); + + public static readonly EventId HealthCheckPublisherBegin = new EventId(102, "HealthCheckPublisherBegin"); + public static readonly EventId HealthCheckPublisherEnd = new EventId(103, "HealthCheckPublisherEnd"); + public static readonly EventId HealthCheckPublisherError = new EventId(104, "HealthCheckPublisherError"); + public static readonly EventId HealthCheckPublisherTimeout = new EventId(104, "HealthCheckPublisherTimeout"); + } + + private static class Logger + { + private static readonly Action _healthCheckPublisherProcessingBegin = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckPublisherProcessingBegin, + "Running health check publishers"); + + private static readonly Action _healthCheckPublisherProcessingEnd = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckPublisherProcessingEnd, + "Health check publisher processing completed after {ElapsedMilliseconds}ms"); + + private static readonly Action _healthCheckPublisherBegin = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckPublisherBegin, + "Running health check publisher '{HealthCheckPublisher}'"); + + private static readonly Action _healthCheckPublisherEnd = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckPublisherEnd, + "Health check '{HealthCheckPublisher}' completed after {ElapsedMilliseconds}ms"); + + private static readonly Action _healthCheckPublisherError = LoggerMessage.Define( + LogLevel.Error, + EventIds.HealthCheckPublisherError, + "Health check {HealthCheckPublisher} threw an unhandled exception after {ElapsedMilliseconds}ms"); + + private static readonly Action _healthCheckPublisherTimeout = LoggerMessage.Define( + LogLevel.Error, + EventIds.HealthCheckPublisherTimeout, + "Health check {HealthCheckPublisher} was canceled after {ElapsedMilliseconds}ms"); + + public static void HealthCheckPublisherProcessingBegin(ILogger logger) + { + _healthCheckPublisherProcessingBegin(logger, null); + } + + public static void HealthCheckPublisherProcessingEnd(ILogger logger, TimeSpan duration, Exception exception = null) + { + _healthCheckPublisherProcessingEnd(logger, duration.TotalMilliseconds, exception); + } + + public static void HealthCheckPublisherBegin(ILogger logger, IHealthCheckPublisher publisher) + { + _healthCheckPublisherBegin(logger, publisher, null); + } + + public static void HealthCheckPublisherEnd(ILogger logger, IHealthCheckPublisher publisher, TimeSpan duration) + { + _healthCheckPublisherEnd(logger, publisher, duration.TotalMilliseconds, null); + } + + public static void HealthCheckPublisherError(ILogger logger, IHealthCheckPublisher publisher, TimeSpan duration, Exception exception) + { + _healthCheckPublisherError(logger, publisher, duration.TotalMilliseconds, exception); + } + + public static void HealthCheckPublisherTimeout(ILogger logger, IHealthCheckPublisher publisher, TimeSpan duration) + { + _healthCheckPublisherTimeout(logger, publisher, duration.TotalMilliseconds, null); + } + } + } +} diff --git a/src/HealthChecks/HealthChecks/src/HealthCheckPublisherOptions.cs b/src/HealthChecks/HealthChecks/src/HealthCheckPublisherOptions.cs new file mode 100644 index 0000000000..1313718af8 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/HealthCheckPublisherOptions.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; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Options for the default service that executes instances. + /// + public sealed class HealthCheckPublisherOptions + { + private TimeSpan _delay; + private TimeSpan _period; + + public HealthCheckPublisherOptions() + { + _delay = TimeSpan.FromSeconds(5); + _period = TimeSpan.FromSeconds(30); + } + + /// + /// Gets or sets the initial delay applied after the application starts before executing + /// instances. The delay is applied once at startup, and does + /// not apply to subsequent iterations. The default value is 5 seconds. + /// + public TimeSpan Delay + { + get => _delay; + set + { + if (value == System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentException($"The {nameof(Delay)} must not be infinite.", nameof(value)); + } + + _delay = value; + } + } + + /// + /// Gets or sets the period of execution. The default value is + /// 30 seconds. + /// + /// + /// The cannot be set to a value lower than 1 second. + /// + public TimeSpan Period + { + get => _period; + set + { + if (value < TimeSpan.FromSeconds(1)) + { + throw new ArgumentException($"The {nameof(Period)} must be greater than or equal to one second.", nameof(value)); + } + + if (value == System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentException($"The {nameof(Period)} must not be infinite.", nameof(value)); + } + + _delay = value; + } + } + + /// + /// Gets or sets a predicate that is used to filter the set of health checks executed. + /// + /// + /// If is null, the health check publisher service will run all + /// registered health checks - this is the default behavior. To run a subset of health checks, + /// provide a function that filters the set of checks. The predicate will be evaluated each period. + /// + public Func Predicate { get; set; } + + /// + /// Gets or sets the timeout for executing the health checks an all + /// instances. Use to execute with no timeout. + /// The default value is 30 seconds. + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); + } +} diff --git a/src/HealthChecks/HealthChecks/src/HealthCheckService.cs b/src/HealthChecks/HealthChecks/src/HealthCheckService.cs new file mode 100644 index 0000000000..e4a128148d --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/HealthCheckService.cs @@ -0,0 +1,61 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// A service which can be used to check the status of instances + /// registered in the application. + /// + /// + /// + /// The default implementation of is registered in the dependency + /// injection container as a singleton service by calling + /// . + /// + /// + /// The returned by + /// + /// provides a convenience API for registering health checks. + /// + /// + /// implementations can be registered through extension methods provided by + /// . + /// + /// + public abstract class HealthCheckService + { + /// + /// Runs all the health checks in the application and returns the aggregated status. + /// + /// A which can be used to cancel the health checks. + /// + /// A which will complete when all the health checks have been run, + /// yielding a containing the results. + /// + public Task CheckHealthAsync(CancellationToken cancellationToken = default) + { + return CheckHealthAsync(predicate: null, cancellationToken); + } + + /// + /// Runs the provided health checks and returns the aggregated status + /// + /// + /// A predicate that can be used to include health checks based on user-defined criteria. + /// + /// A which can be used to cancel the health checks. + /// + /// A which will complete when all the health checks have been run, + /// yielding a containing the results. + /// + public abstract Task CheckHealthAsync( + Func predicate, + CancellationToken cancellationToken = default); + } +} diff --git a/src/HealthChecks/HealthChecks/src/HealthCheckServiceOptions.cs b/src/HealthChecks/HealthChecks/src/HealthCheckServiceOptions.cs new file mode 100644 index 0000000000..b8dfdb9b40 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/HealthCheckServiceOptions.cs @@ -0,0 +1,18 @@ +// 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.Collections.Generic; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Options for the default implementation of + /// + public sealed class HealthCheckServiceOptions + { + /// + /// Gets the health check registrations. + /// + public ICollection Registrations { get; } = new List(); + } +} diff --git a/src/HealthChecks/HealthChecks/src/Microsoft.Extensions.Diagnostics.HealthChecks.csproj b/src/HealthChecks/HealthChecks/src/Microsoft.Extensions.Diagnostics.HealthChecks.csproj new file mode 100644 index 0000000000..d0b1c97ef0 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/Microsoft.Extensions.Diagnostics.HealthChecks.csproj @@ -0,0 +1,26 @@ + + + Components for performing health checks in .NET applications + +Commonly Used Types: +Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckService +Microsoft.Extensions.Diagnostics.HealthChecks.IHealthChecksBuilder + + netstandard2.0 + $(NoWarn);CS1591 + true + diagnostics;healthchecks + + + + + + + + + + + + + + diff --git a/src/HealthChecks/HealthChecks/src/Properties/AssemblyInfo.cs b/src/HealthChecks/HealthChecks/src/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..13e969bfad --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.Extensions.Diagnostics.HealthChecks.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] \ No newline at end of file diff --git a/src/HealthChecks/HealthChecks/src/baseline.netcore.json b/src/HealthChecks/HealthChecks/src/baseline.netcore.json new file mode 100644 index 0000000000..cb2fe053f1 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/baseline.netcore.json @@ -0,0 +1,5 @@ +{ + "AssemblyIdentity": "Microsoft.Extensions.Diagnostics.HealthChecks, Version=2.1.1.0, Culture=neutral, PublicKeyToken=adb9793829ddae60", + "Types": [ + ] +} \ No newline at end of file diff --git a/src/HealthChecks/HealthChecks/test/DefaultHealthCheckServiceTest.cs b/src/HealthChecks/HealthChecks/test/DefaultHealthCheckServiceTest.cs new file mode 100644 index 0000000000..9ab991204e --- /dev/null +++ b/src/HealthChecks/HealthChecks/test/DefaultHealthCheckServiceTest.cs @@ -0,0 +1,419 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + public class DefaultHealthCheckServiceTest + { + [Fact] + public void Constructor_ThrowsUsefulExceptionForDuplicateNames() + { + // Arrange + // + // Doing this the old fashioned way so we can verify that the exception comes + // from the constructor. + var serviceCollection = new ServiceCollection(); + serviceCollection.AddLogging(); + serviceCollection.AddOptions(); + serviceCollection.AddHealthChecks() + .AddCheck("Foo", new DelegateHealthCheck(_ => Task.FromResult(HealthCheckResult.Healthy()))) + .AddCheck("Foo", new DelegateHealthCheck(_ => Task.FromResult(HealthCheckResult.Healthy()))) + .AddCheck("Bar", new DelegateHealthCheck(_ => Task.FromResult(HealthCheckResult.Healthy()))) + .AddCheck("Baz", new DelegateHealthCheck(_ => Task.FromResult(HealthCheckResult.Healthy()))) + .AddCheck("Baz", new DelegateHealthCheck(_ => Task.FromResult(HealthCheckResult.Healthy()))); + + var services = serviceCollection.BuildServiceProvider(); + + var scopeFactory = services.GetRequiredService(); + var options = services.GetRequiredService>(); + var logger = services.GetRequiredService>(); + + // Act + var exception = Assert.Throws(() => new DefaultHealthCheckService(scopeFactory, options, logger)); + + // Assert + Assert.StartsWith($"Duplicate health checks were registered with the name(s): Foo, Baz", exception.Message); + } + + [Fact] + public async Task CheckAsync_RunsAllChecksAndAggregatesResultsAsync() + { + const string DataKey = "Foo"; + const string DataValue = "Bar"; + const string DegradedMessage = "I'm not feeling so good"; + const string UnhealthyMessage = "Halp!"; + const string HealthyMessage = "Everything is A-OK"; + var exception = new Exception("Things are pretty bad!"); + + // Arrange + var data = new Dictionary() + { + { DataKey, DataValue } + }; + + var service = CreateHealthChecksService(b => + { + b.AddAsyncCheck("HealthyCheck", _ => Task.FromResult(HealthCheckResult.Healthy(HealthyMessage, data))); + b.AddAsyncCheck("DegradedCheck", _ => Task.FromResult(HealthCheckResult.Degraded(DegradedMessage))); + b.AddAsyncCheck("UnhealthyCheck", _ => Task.FromResult(HealthCheckResult.Unhealthy(UnhealthyMessage, exception))); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection( + results.Entries.OrderBy(kvp => kvp.Key), + actual => + { + Assert.Equal("DegradedCheck", actual.Key); + Assert.Equal(DegradedMessage, actual.Value.Description); + Assert.Equal(HealthStatus.Degraded, actual.Value.Status); + Assert.Null(actual.Value.Exception); + Assert.Empty(actual.Value.Data); + }, + actual => + { + Assert.Equal("HealthyCheck", actual.Key); + Assert.Equal(HealthyMessage, actual.Value.Description); + Assert.Equal(HealthStatus.Healthy, actual.Value.Status); + Assert.Null(actual.Value.Exception); + Assert.Collection(actual.Value.Data, item => + { + Assert.Equal(DataKey, item.Key); + Assert.Equal(DataValue, item.Value); + }); + }, + actual => + { + Assert.Equal("UnhealthyCheck", actual.Key); + Assert.Equal(UnhealthyMessage, actual.Value.Description); + Assert.Equal(HealthStatus.Unhealthy, actual.Value.Status); + Assert.Same(exception, actual.Value.Exception); + Assert.Empty(actual.Value.Data); + }); + } + + [Fact] + public async Task CheckAsync_RunsFilteredChecksAndAggregatesResultsAsync() + { + const string DataKey = "Foo"; + const string DataValue = "Bar"; + const string DegradedMessage = "I'm not feeling so good"; + const string UnhealthyMessage = "Halp!"; + const string HealthyMessage = "Everything is A-OK"; + var exception = new Exception("Things are pretty bad!"); + + // Arrange + var data = new Dictionary + { + { DataKey, DataValue } + }; + + var service = CreateHealthChecksService(b => + { + b.AddAsyncCheck("HealthyCheck", _ => Task.FromResult(HealthCheckResult.Healthy(HealthyMessage, data))); + b.AddAsyncCheck("DegradedCheck", _ => Task.FromResult(HealthCheckResult.Degraded(DegradedMessage))); + b.AddAsyncCheck("UnhealthyCheck", _ => Task.FromResult(HealthCheckResult.Unhealthy(UnhealthyMessage, exception))); + }); + + // Act + var results = await service.CheckHealthAsync(c => c.Name == "HealthyCheck"); + + // Assert + Assert.Collection(results.Entries, + actual => + { + Assert.Equal("HealthyCheck", actual.Key); + Assert.Equal(HealthyMessage, actual.Value.Description); + Assert.Equal(HealthStatus.Healthy, actual.Value.Status); + Assert.Null(actual.Value.Exception); + Assert.Collection(actual.Value.Data, item => + { + Assert.Equal(DataKey, item.Key); + Assert.Equal(DataValue, item.Value); + }); + }); + } + + [Fact] + public async Task CheckHealthAsync_SetsRegistrationForEachCheck() + { + // Arrange + var thrownException = new InvalidOperationException("Whoops!"); + var faultedException = new InvalidOperationException("Ohnoes!"); + + var service = CreateHealthChecksService(b => + { + b.AddCheck("A"); + b.AddCheck("B"); + b.AddCheck("C"); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection( + results.Entries, + actual => + { + Assert.Equal("A", actual.Key); + Assert.Collection( + actual.Value.Data, + kvp => Assert.Equal(kvp, new KeyValuePair("name", "A"))); + }, + actual => + { + Assert.Equal("B", actual.Key); + Assert.Collection( + actual.Value.Data, + kvp => Assert.Equal(kvp, new KeyValuePair("name", "B"))); + }, + actual => + { + Assert.Equal("C", actual.Key); + Assert.Collection( + actual.Value.Data, + kvp => Assert.Equal(kvp, new KeyValuePair("name", "C"))); + }); + } + + [Fact] + public async Task CheckHealthAsync_Cancellation_CanPropagate() + { + // Arrange + var insideCheck = new TaskCompletionSource(); + + var service = CreateHealthChecksService(b => + { + b.AddAsyncCheck("cancels", async ct => + { + insideCheck.SetResult(null); + + await Task.Delay(10000, ct); + return HealthCheckResult.Unhealthy(); + }); + }); + + var cancel = new CancellationTokenSource(); + var task = service.CheckHealthAsync(cancel.Token); + + // After this returns we know the check has started + await insideCheck.Task; + + cancel.Cancel(); + + // Act & Assert + await Assert.ThrowsAsync(async () => await task); + } + + [Fact] + public async Task CheckHealthAsync_ConvertsExceptionInHealthCheckToUnhealthyResultAsync() + { + // Arrange + var thrownException = new InvalidOperationException("Whoops!"); + var faultedException = new InvalidOperationException("Ohnoes!"); + + var service = CreateHealthChecksService(b => + { + b.AddAsyncCheck("Throws", ct => throw thrownException); + b.AddAsyncCheck("Faults", ct => Task.FromException(faultedException)); + b.AddAsyncCheck("Succeeds", ct => Task.FromResult(HealthCheckResult.Healthy())); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection( + results.Entries, + actual => + { + Assert.Equal("Throws", actual.Key); + Assert.Equal(thrownException.Message, actual.Value.Description); + Assert.Equal(HealthStatus.Unhealthy, actual.Value.Status); + Assert.Same(thrownException, actual.Value.Exception); + }, + actual => + { + Assert.Equal("Faults", actual.Key); + Assert.Equal(faultedException.Message, actual.Value.Description); + Assert.Equal(HealthStatus.Unhealthy, actual.Value.Status); + Assert.Same(faultedException, actual.Value.Exception); + }, + actual => + { + Assert.Equal("Succeeds", actual.Key); + Assert.Null(actual.Value.Description); + Assert.Equal(HealthStatus.Healthy, actual.Value.Status); + Assert.Null(actual.Value.Exception); + }); + } + + [Fact] + public async Task CheckHealthAsync_SetsUpALoggerScopeForEachCheck() + { + // Arrange + var sink = new TestSink(); + var check = new DelegateHealthCheck(cancellationToken => + { + Assert.Collection(sink.Scopes, + actual => + { + Assert.Equal(actual.LoggerName, typeof(DefaultHealthCheckService).FullName); + Assert.Collection((IEnumerable>)actual.Scope, + item => + { + Assert.Equal("HealthCheckName", item.Key); + Assert.Equal("TestScope", item.Value); + }); + }); + return Task.FromResult(HealthCheckResult.Healthy()); + }); + + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var service = CreateHealthChecksService(b => + { + // Override the logger factory for testing + b.Services.AddSingleton(loggerFactory); + + b.AddCheck("TestScope", check); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection(results.Entries, actual => + { + Assert.Equal("TestScope", actual.Key); + Assert.Equal(HealthStatus.Healthy, actual.Value.Status); + }); + } + + [Fact] + public async Task CheckHealthAsync_CheckCanDependOnTransientService() + { + // Arrange + var service = CreateHealthChecksService(b => + { + b.Services.AddTransient(); + + b.AddCheck("Test"); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection( + results.Entries, + actual => + { + Assert.Equal("Test", actual.Key); + Assert.Equal(HealthStatus.Healthy, actual.Value.Status); + }); + } + + [Fact] + public async Task CheckHealthAsync_CheckCanDependOnScopedService() + { + // Arrange + var service = CreateHealthChecksService(b => + { + b.Services.AddScoped(); + + b.AddCheck("Test"); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection( + results.Entries, + actual => + { + Assert.Equal("Test", actual.Key); + Assert.Equal(HealthStatus.Healthy, actual.Value.Status); + }); + } + + [Fact] + public async Task CheckHealthAsync_CheckCanDependOnSingletonService() + { + // Arrange + var service = CreateHealthChecksService(b => + { + b.Services.AddSingleton(); + + b.AddCheck("Test"); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection( + results.Entries, + actual => + { + Assert.Equal("Test", actual.Key); + Assert.Equal(HealthStatus.Healthy, actual.Value.Status); + }); + } + + private static DefaultHealthCheckService CreateHealthChecksService(Action configure) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + + var builder = services.AddHealthChecks(); + if (configure != null) + { + configure(builder); + } + + return (DefaultHealthCheckService)services.BuildServiceProvider(validateScopes: true).GetRequiredService(); + } + + private class AnotherService { } + + private class CheckWithServiceDependency : IHealthCheck + { + public CheckWithServiceDependency(AnotherService _) + { + } + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + return Task.FromResult(HealthCheckResult.Healthy()); + } + } + + private class NameCapturingCheck : IHealthCheck + { + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + var data = new Dictionary() + { + { "name", context.Registration.Name }, + }; + return Task.FromResult(HealthCheckResult.Healthy(data: data)); + } + } + } +} diff --git a/src/HealthChecks/HealthChecks/test/DependencyInjection/HealthChecksBuilderTest.cs b/src/HealthChecks/HealthChecks/test/DependencyInjection/HealthChecksBuilderTest.cs new file mode 100644 index 0000000000..4235f152a2 --- /dev/null +++ b/src/HealthChecks/HealthChecks/test/DependencyInjection/HealthChecksBuilderTest.cs @@ -0,0 +1,257 @@ +// 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; +using System.Threading.Tasks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.Extensions.DependencyInjection +{ + // Integration tests for extension methods on IHealthCheckBuilder + // + // We test the longest overload of each 'family' of Add...Check methods, since they chain to each other. + public class HealthChecksBuilderTest + { + [Fact] + public void AddCheck_Instance() + { + // Arrange + var instance = new DelegateHealthCheck((_) => + { + return Task.FromResult(HealthCheckResult.Healthy()); + }); + + var services = CreateServices(); + services.AddHealthChecks().AddCheck("test", failureStatus: HealthStatus.Degraded,tags: new[] { "tag", }, instance: instance); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>().Value; + + // Assert + var registration = Assert.Single(options.Registrations); + Assert.Equal("test", registration.Name); + Assert.Equal(HealthStatus.Degraded, registration.FailureStatus); + Assert.Equal(new[] { "tag", }, registration.Tags); + Assert.Same(instance, registration.Factory(serviceProvider)); + } + + [Fact] + public void AddCheck_T_TypeActivated() + { + // Arrange + var services = CreateServices(); + services.AddHealthChecks().AddCheck("test", failureStatus: HealthStatus.Degraded, tags: new[] { "tag", }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>().Value; + + // Assert + var registration = Assert.Single(options.Registrations); + Assert.Equal("test", registration.Name); + Assert.Equal(HealthStatus.Degraded, registration.FailureStatus); + Assert.Equal(new[] { "tag", }, registration.Tags); + Assert.IsType(registration.Factory(serviceProvider)); + } + + [Fact] + public void AddCheck_T_Service() + { + // Arrange + var instance = new TestHealthCheck(); + + var services = CreateServices(); + services.AddSingleton(instance); + services.AddHealthChecks().AddCheck("test", failureStatus: HealthStatus.Degraded, tags: new[] { "tag", }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>().Value; + + // Assert + var registration = Assert.Single(options.Registrations); + Assert.Equal("test", registration.Name); + Assert.Equal(HealthStatus.Degraded, registration.FailureStatus); + Assert.Equal(new[] { "tag", }, registration.Tags); + Assert.Same(instance, registration.Factory(serviceProvider)); + } + + [Fact] + public void AddTypeActivatedCheck() + { + // Arrange + var services = CreateServices(); + services + .AddHealthChecks() + .AddTypeActivatedCheck("test", failureStatus: HealthStatus.Degraded, tags: new[] { "tag", }, args: new object[] { 5, "hi", }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>().Value; + + // Assert + var registration = Assert.Single(options.Registrations); + Assert.Equal("test", registration.Name); + Assert.Equal(HealthStatus.Degraded, registration.FailureStatus); + Assert.Equal(new[] { "tag", }, registration.Tags); + + var check = Assert.IsType(registration.Factory(serviceProvider)); + Assert.Equal(5, check.I); + Assert.Equal("hi", check.S); + } + + [Fact] + public void AddDelegateCheck_NoArg() + { + // Arrange + var services = CreateServices(); + services.AddHealthChecks().AddCheck("test", tags: new[] { "tag", }, check: () => + { + return HealthCheckResult.Healthy(); + }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>().Value; + + // Assert + var registration = Assert.Single(options.Registrations); + Assert.Equal("test", registration.Name); + Assert.Equal(HealthStatus.Unhealthy, registration.FailureStatus); + Assert.Equal(new[] { "tag", }, registration.Tags); + Assert.IsType(registration.Factory(serviceProvider)); + } + + [Fact] + public void AddDelegateCheck_CancellationToken() + { + // Arrange + var services = CreateServices(); + services.AddHealthChecks().AddCheck("test", (_) => + { + return HealthCheckResult.Degraded(); + }, tags: new[] { "tag", }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>().Value; + + // Assert + var registration = Assert.Single(options.Registrations); + Assert.Equal("test", registration.Name); + Assert.Equal(HealthStatus.Unhealthy, registration.FailureStatus); + Assert.Equal(new[] { "tag", }, registration.Tags); + Assert.IsType(registration.Factory(serviceProvider)); + } + + [Fact] + public void AddAsyncDelegateCheck_NoArg() + { + // Arrange + var services = CreateServices(); + services.AddHealthChecks().AddAsyncCheck("test", () => + { + return Task.FromResult(HealthCheckResult.Healthy()); + }, tags: new[] { "tag", }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>().Value; + + // Assert + var registration = Assert.Single(options.Registrations); + Assert.Equal("test", registration.Name); + Assert.Equal(HealthStatus.Unhealthy, registration.FailureStatus); + Assert.Equal(new[] { "tag", }, registration.Tags); + Assert.IsType(registration.Factory(serviceProvider)); + } + + [Fact] + public void AddAsyncDelegateCheck_CancellationToken() + { + // Arrange + var services = CreateServices(); + services.AddHealthChecks().AddAsyncCheck("test", (_) => + { + return Task.FromResult(HealthCheckResult.Unhealthy()); + }, tags: new[] { "tag", }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>().Value; + + // Assert + var registration = Assert.Single(options.Registrations); + Assert.Equal("test", registration.Name); + Assert.Equal(HealthStatus.Unhealthy, registration.FailureStatus); + Assert.Equal(new[] { "tag", }, registration.Tags); + Assert.IsType(registration.Factory(serviceProvider)); + } + + [Fact] + public void ChecksCanBeRegisteredInMultipleCallsToAddHealthChecks() + { + var services = new ServiceCollection(); + services + .AddHealthChecks() + .AddAsyncCheck("Foo", () => Task.FromResult(HealthCheckResult.Healthy())); + services + .AddHealthChecks() + .AddAsyncCheck("Bar", () => Task.FromResult(HealthCheckResult.Healthy())); + + // Act + var options = services.BuildServiceProvider().GetRequiredService>(); + + // Assert + Assert.Collection( + options.Value.Registrations, + actual => Assert.Equal("Foo", actual.Name), + actual => Assert.Equal("Bar", actual.Name)); + } + + private IServiceCollection CreateServices() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + return services; + } + + private class TestHealthCheck : IHealthCheck + { + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + throw new System.NotImplementedException(); + } + } + + private class TestHealthCheckWithArgs : IHealthCheck + { + public TestHealthCheckWithArgs(int i, string s) + { + I = i; + S = s; + } + + public int I { get; set; } + + public string S { get; set; } + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + throw new System.NotImplementedException(); + } + } + } +} diff --git a/src/HealthChecks/HealthChecks/test/DependencyInjection/ServiceCollectionExtensionsTest.cs b/src/HealthChecks/HealthChecks/test/DependencyInjection/ServiceCollectionExtensionsTest.cs new file mode 100644 index 0000000000..694a97628d --- /dev/null +++ b/src/HealthChecks/HealthChecks/test/DependencyInjection/ServiceCollectionExtensionsTest.cs @@ -0,0 +1,43 @@ +// 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.Linq; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Microsoft.Extensions.DependencyInjection +{ + public class ServiceCollectionExtensionsTest + { + [Fact] + public void AddHealthChecks_RegistersSingletonHealthCheckServiceIdempotently() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddHealthChecks(); + services.AddHealthChecks(); + + // Assert + Assert.Collection(services.OrderBy(s => s.ServiceType.FullName), + actual => + { + Assert.Equal(ServiceLifetime.Singleton, actual.Lifetime); + Assert.Equal(typeof(HealthCheckService), actual.ServiceType); + Assert.Equal(typeof(DefaultHealthCheckService), actual.ImplementationType); + Assert.Null(actual.ImplementationInstance); + Assert.Null(actual.ImplementationFactory); + }, + actual => + { + Assert.Equal(ServiceLifetime.Singleton, actual.Lifetime); + Assert.Equal(typeof(IHostedService), actual.ServiceType); + Assert.Equal(typeof(HealthCheckPublisherHostedService), actual.ImplementationType); + Assert.Null(actual.ImplementationInstance); + Assert.Null(actual.ImplementationFactory); + }); + } + } +} diff --git a/src/HealthChecks/HealthChecks/test/HealthCheckPublisherHostedServiceTest.cs b/src/HealthChecks/HealthChecks/test/HealthCheckPublisherHostedServiceTest.cs new file mode 100644 index 0000000000..94687efcb8 --- /dev/null +++ b/src/HealthChecks/HealthChecks/test/HealthCheckPublisherHostedServiceTest.cs @@ -0,0 +1,528 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + public class HealthCheckPublisherHostedServiceTest + { + [Fact] + public async Task StartAsync_WithoutPublishers_DoesNotStartTimer() + { + // Arrange + var publishers = new IHealthCheckPublisher[] + { + }; + + var service = CreateService(publishers); + + try + { + // Act + await service.StartAsync(); + + // Assert + Assert.False(service.IsTimerRunning); + Assert.False(service.IsStopping); + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task StartAsync_WithPublishers_StartsTimer() + { + // Arrange + var publishers = new IHealthCheckPublisher[] + { + new TestPublisher(), + }; + + var service = CreateService(publishers); + + try + { + // Act + await service.StartAsync(); + + // Assert + Assert.True(service.IsTimerRunning); + Assert.False(service.IsStopping); + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task StartAsync_WithPublishers_StartsTimer_RunsPublishers() + { + // Arrange + var unblock0 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unblock1 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unblock2 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var publishers = new TestPublisher[] + { + new TestPublisher() { Wait = unblock0.Task, }, + new TestPublisher() { Wait = unblock1.Task, }, + new TestPublisher() { Wait = unblock2.Task, }, + }; + + var service = CreateService(publishers, configure: (options) => + { + options.Delay = TimeSpan.FromMilliseconds(0); + }); + + try + { + // Act + await service.StartAsync(); + + await publishers[0].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + await publishers[1].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + await publishers[2].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + + unblock0.SetResult(null); + unblock1.SetResult(null); + unblock2.SetResult(null); + + // Assert + Assert.True(service.IsTimerRunning); + Assert.False(service.IsStopping); + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task StopAsync_CancelsExecution() + { + // Arrange + var unblock = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var publishers = new TestPublisher[] + { + new TestPublisher() { Wait = unblock.Task, } + }; + + var service = CreateService(publishers); + + try + { + await service.StartAsync(); + + // Start execution + var running = service.RunAsync(); + + // Wait for the publisher to see the cancellation token + await publishers[0].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + Assert.Single(publishers[0].Entries); + + // Act + await service.StopAsync(); // Trigger cancellation + + // Assert + await AssertCancelledAsync(publishers[0].Entries[0].cancellationToken); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + + unblock.SetResult(null); + + await running.TimeoutAfter(TimeSpan.FromSeconds(10)); + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task RunAsync_WaitsForCompletion_Single() + { + // Arrange + var sink = new TestSink(); + + var unblock = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var publishers = new TestPublisher[] + { + new TestPublisher() { Wait = unblock.Task, }, + }; + + var service = CreateService(publishers, sink: sink); + + try + { + await service.StartAsync(); + + // Act + var running = service.RunAsync(); + + await publishers[0].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + + unblock.SetResult(null); + + await running.TimeoutAfter(TimeSpan.FromSeconds(10)); + + // Assert + Assert.True(service.IsTimerRunning); + Assert.False(service.IsStopping); + + for (var i = 0; i < publishers.Length; i++) + { + var report = Assert.Single(publishers[i].Entries).report; + Assert.Equal(new[] { "one", "two", }, report.Entries.Keys.OrderBy(k => k)); + } + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + + Assert.Collection( + sink.Writes, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckEnd, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckEnd, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingEnd, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherBegin, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherEnd, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingEnd, entry.EventId); }); + } + + // Not testing logs here to avoid differences in logging order + [Fact] + public async Task RunAsync_WaitsForCompletion_Multiple() + { + // Arrange + var unblock0 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unblock1 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unblock2 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var publishers = new TestPublisher[] + { + new TestPublisher() { Wait = unblock0.Task, }, + new TestPublisher() { Wait = unblock1.Task, }, + new TestPublisher() { Wait = unblock2.Task, }, + }; + + var service = CreateService(publishers); + + try + { + await service.StartAsync(); + + // Act + var running = service.RunAsync(); + + await publishers[0].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + await publishers[1].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + await publishers[2].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + + unblock0.SetResult(null); + unblock1.SetResult(null); + unblock2.SetResult(null); + + await running.TimeoutAfter(TimeSpan.FromSeconds(10)); + + // Assert + Assert.True(service.IsTimerRunning); + Assert.False(service.IsStopping); + + for (var i = 0; i < publishers.Length; i++) + { + var report = Assert.Single(publishers[i].Entries).report; + Assert.Equal(new[] { "one", "two", }, report.Entries.Keys.OrderBy(k => k)); + } + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task RunAsync_PublishersCanTimeout() + { + // Arrange + var sink = new TestSink(); + var unblock = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var publishers = new TestPublisher[] + { + new TestPublisher() { Wait = unblock.Task, }, + }; + + var service = CreateService(publishers, sink: sink, configure: (options) => + { + options.Timeout = TimeSpan.FromMilliseconds(50); + }); + + try + { + await service.StartAsync(); + + // Act + var running = service.RunAsync(); + + await publishers[0].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + + await AssertCancelledAsync(publishers[0].Entries[0].cancellationToken); + + unblock.SetResult(null); + + await running.TimeoutAfter(TimeSpan.FromSeconds(10)); + + // Assert + Assert.True(service.IsTimerRunning); + Assert.False(service.IsStopping); + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + + Assert.Collection( + sink.Writes, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckEnd, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckEnd, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingEnd, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherBegin, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherTimeout, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingEnd, entry.EventId); }); + } + + [Fact] + public async Task RunAsync_CanFilterHealthChecks() + { + // Arrange + var publishers = new TestPublisher[] + { + new TestPublisher(), + new TestPublisher(), + }; + + var service = CreateService(publishers, configure: (options) => + { + options.Predicate = (r) => r.Name == "one"; + }); + + try + { + await service.StartAsync(); + + // Act + await service.RunAsync().TimeoutAfter(TimeSpan.FromSeconds(10)); + + // Assert + for (var i = 0; i < publishers.Length; i++) + { + var report = Assert.Single(publishers[i].Entries).report; + Assert.Equal(new[] { "one", }, report.Entries.Keys.OrderBy(k => k)); + } + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task RunAsync_HandlesExceptions() + { + // Arrange + var sink = new TestSink(); + var publishers = new TestPublisher[] + { + new TestPublisher() { Exception = new InvalidTimeZoneException(), }, + }; + + var service = CreateService(publishers, sink: sink); + + try + { + await service.StartAsync(); + + // Act + await service.RunAsync().TimeoutAfter(TimeSpan.FromSeconds(10)); + + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + + Assert.Collection( + sink.Writes, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckEnd, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckEnd, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingEnd, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherBegin, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherError, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingEnd, entry.EventId); }); + } + + // Not testing logging here to avoid flaky ordering issues + [Fact] + public async Task RunAsync_HandlesExceptions_Multiple() + { + // Arrange + var sink = new TestSink(); + var publishers = new TestPublisher[] + { + new TestPublisher() { Exception = new InvalidTimeZoneException(), }, + new TestPublisher(), + new TestPublisher() { Exception = new InvalidTimeZoneException(), }, + }; + + var service = CreateService(publishers, sink: sink); + + try + { + await service.StartAsync(); + + // Act + await service.RunAsync().TimeoutAfter(TimeSpan.FromSeconds(10)); + + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + private HealthCheckPublisherHostedService CreateService( + IHealthCheckPublisher[] publishers, + Action configure = null, + TestSink sink = null) + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddOptions(); + serviceCollection.AddLogging(); + serviceCollection.AddHealthChecks() + .AddCheck("one", () => { return HealthCheckResult.Healthy(); }) + .AddCheck("two", () => { return HealthCheckResult.Healthy(); }); + + // Choosing big values for tests to make sure that we're not dependent on the defaults. + // All of the tests that rely on the timer will set their own values for speed. + serviceCollection.Configure(options => + { + options.Delay = TimeSpan.FromMinutes(5); + options.Period = TimeSpan.FromMinutes(5); + options.Timeout = TimeSpan.FromMinutes(5); + }); + + if (publishers != null) + { + for (var i = 0; i < publishers.Length; i++) + { + serviceCollection.AddSingleton(publishers[i]); + } + } + + if (configure != null) + { + serviceCollection.Configure(configure); + } + + if (sink != null) + { + serviceCollection.AddSingleton(new TestLoggerFactory(sink, enabled: true)); + } + + var services = serviceCollection.BuildServiceProvider(); + return services.GetServices().OfType< HealthCheckPublisherHostedService>().Single(); + } + + private static async Task AssertCancelledAsync(CancellationToken cancellationToken) + { + await Assert.ThrowsAsync(() => Task.Delay(TimeSpan.FromSeconds(10), cancellationToken)); + } + + private class TestPublisher : IHealthCheckPublisher + { + private TaskCompletionSource _started; + + public TestPublisher() + { + _started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public List<(HealthReport report, CancellationToken cancellationToken)> Entries { get; } = new List<(HealthReport report, CancellationToken cancellationToken)>(); + + public Exception Exception { get; set; } + + public Task Started => _started.Task; + + public Task Wait { get; set; } + + public async Task PublishAsync(HealthReport report, CancellationToken cancellationToken) + { + Entries.Add((report, cancellationToken)); + + // Signal that we've started + _started.SetResult(null); + + if (Wait != null) + { + await Wait; + } + + if (Exception != null) + { + throw Exception; + } + + cancellationToken.ThrowIfCancellationRequested(); + } + } + } +} diff --git a/src/HealthChecks/HealthChecks/test/HealthReportTest.cs b/src/HealthChecks/HealthChecks/test/HealthReportTest.cs new file mode 100644 index 0000000000..07f8e5a8e3 --- /dev/null +++ b/src/HealthChecks/HealthChecks/test/HealthReportTest.cs @@ -0,0 +1,45 @@ +// 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.Collections.Generic; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + public class HealthReportTest + { + [Theory] + [InlineData(HealthStatus.Healthy)] + [InlineData(HealthStatus.Degraded)] + [InlineData(HealthStatus.Unhealthy)] + public void Status_MatchesWorstStatusInResults(HealthStatus status) + { + var result = new HealthReport(new Dictionary() + { + {"Foo", new HealthReportEntry(HealthStatus.Healthy, null,TimeSpan.MinValue, null, null) }, + {"Bar", new HealthReportEntry(HealthStatus.Healthy, null, TimeSpan.MinValue,null, null) }, + {"Baz", new HealthReportEntry(status, exception: null, description: null,duration:TimeSpan.MinValue, data: null) }, + {"Quick", new HealthReportEntry(HealthStatus.Healthy, null, TimeSpan.MinValue, null, null) }, + {"Quack", new HealthReportEntry(HealthStatus.Healthy, null, TimeSpan.MinValue, null, null) }, + {"Quock", new HealthReportEntry(HealthStatus.Healthy, null, TimeSpan.MinValue, null, null) }, + }, totalDuration: TimeSpan.MinValue); + + Assert.Equal(status, result.Status); + } + + [Theory] + [InlineData(200)] + [InlineData(300)] + [InlineData(400)] + public void TotalDuration_MatchesTotalDurationParameter(int milliseconds) + { + var result = new HealthReport(new Dictionary() + { + {"Foo", new HealthReportEntry(HealthStatus.Healthy, null,TimeSpan.MinValue, null, null) } + }, totalDuration: TimeSpan.FromMilliseconds(milliseconds)); + + Assert.Equal(TimeSpan.FromMilliseconds(milliseconds), result.TotalDuration); + } + } +} diff --git a/src/HealthChecks/HealthChecks/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests.csproj b/src/HealthChecks/HealthChecks/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests.csproj new file mode 100644 index 0000000000..e822f6a7a0 --- /dev/null +++ b/src/HealthChecks/HealthChecks/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests.csproj @@ -0,0 +1,12 @@ + + + + $(StandardTestTfms) + Microsoft.Extensions.Diagnostics.HealthChecks + + + + + + + From 1bfa807e48ee04f8837c1e0c3467dc785271fc1b Mon Sep 17 00:00:00 2001 From: Hao Kung Date: Thu, 14 Mar 2019 21:14:45 -0700 Subject: [PATCH 6/6] Port config fix to 2.2 (dotnet/extensions#1221) - port of dotnet/extensions#1202 - with PR tweaks for 2.2 - e.g. adjust Microsoft.Extensions.Configuration.FunctionalTests.csproj to match layout here - update PatchConfig.props and NuGetPackageVerifier.json\n\nCommit migrated from https://github.com/dotnet/extensions/commit/9ebff1a64e5ea460da1e9837fab3e96939e0ad0e --- .../src/KeyPerFileConfigurationProvider.cs | 7 ++-- .../test/KeyPerFileTests.cs | 35 +++++++++++++++++++ ...ions.Configuration.KeyPerFile.Tests.csproj | 1 + 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationProvider.cs b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationProvider.cs index 4748895744..6e4234ecf3 100644 --- a/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationProvider.cs +++ b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationProvider.cs @@ -31,12 +31,13 @@ namespace Microsoft.Extensions.Configuration.KeyPerFile /// public override void Load() { - Data = new Dictionary(StringComparer.OrdinalIgnoreCase); + var data = new Dictionary(StringComparer.OrdinalIgnoreCase); if (Source.FileProvider == null) { if (Source.Optional) { + Data = data; return; } else @@ -63,10 +64,12 @@ namespace Microsoft.Extensions.Configuration.KeyPerFile { if (Source.IgnoreCondition == null || !Source.IgnoreCondition(file.Name)) { - Data.Add(NormalizeKey(file.Name), TrimNewLine(streamReader.ReadToEnd())); + data.Add(NormalizeKey(file.Name), TrimNewLine(streamReader.ReadToEnd())); } } } + + Data = data; } } } diff --git a/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs b/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs index d409c0eab0..499c25106c 100644 --- a/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs +++ b/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs @@ -4,6 +4,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Primitives; using Xunit; @@ -177,6 +179,39 @@ namespace Microsoft.Extensions.Configuration.KeyPerFile.Test Assert.Equal("SecretValue1", config["ignore.Secret1"]); Assert.Equal("SecretValue2", config["Secret2"]); } + + [Fact] + public void BindingDoesNotThrowIfReloadedDuringBinding() + { + var testFileProvider = new TestFileProvider( + new TestFile("Number", "-2"), + new TestFile("Text", "Foo")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => o.FileProvider = testFileProvider) + .Build(); + + MyOptions options = null; + + using (var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(250))) + { + _ = Task.Run(() => { while (!cts.IsCancellationRequested) config.Reload(); }); + + while (!cts.IsCancellationRequested) + { + options = config.Get(); + } + } + + Assert.Equal(-2, options.Number); + Assert.Equal("Foo", options.Text); + } + + private sealed class MyOptions + { + public int Number { get; set; } + public string Text { get; set; } + } } class TestFileProvider : IFileProvider diff --git a/src/Configuration.KeyPerFile/test/Microsoft.Extensions.Configuration.KeyPerFile.Tests.csproj b/src/Configuration.KeyPerFile/test/Microsoft.Extensions.Configuration.KeyPerFile.Tests.csproj index 4205f4ae13..154fd5bb62 100644 --- a/src/Configuration.KeyPerFile/test/Microsoft.Extensions.Configuration.KeyPerFile.Tests.csproj +++ b/src/Configuration.KeyPerFile/test/Microsoft.Extensions.Configuration.KeyPerFile.Tests.csproj @@ -5,6 +5,7 @@ +