diff --git a/src/Core/DocumentationLinks.cs b/src/Core/DocumentationLinks.cs index ad4c4f3e7..7446139ec 100644 --- a/src/Core/DocumentationLinks.cs +++ b/src/Core/DocumentationLinks.cs @@ -39,6 +39,7 @@ public static class DocumentationLinks public const string OpenInIdeIssueLocation = "https://docs.sonarsource.com/sonarqube-for-ide/visual-studio/troubleshooting/#no-matching-issue-found"; public const string OpenInIdeBindingSetup = "https://docs.sonarsource.com/sonarqube-for-ide/visual-studio/troubleshooting/#no-matching-project-found"; public const string UnbindingProject = "https://docs.sonarsource.com/sonarqube-for-ide/visual-studio/team-features/connected-mode-setup/#unbinding-a-project"; + public const string SslCertificate = "https://docs.sonarsource.com/sonarqube-for-ide/intellij/team-features/advanced-configuration/#client-ssl-certificates"; public static readonly Uri UnbindingProjectUri = new(UnbindingProject); public static readonly Uri ConnectedModeUri = new(ConnectedMode); diff --git a/src/SLCore.Listeners.UnitTests/Implementation/Http/HttpConfigurationListenerTests.cs b/src/SLCore.Listeners.UnitTests/Implementation/Http/HttpConfigurationListenerTests.cs index 37a994866..c1eb94a58 100644 --- a/src/SLCore.Listeners.UnitTests/Implementation/Http/HttpConfigurationListenerTests.cs +++ b/src/SLCore.Listeners.UnitTests/Implementation/Http/HttpConfigurationListenerTests.cs @@ -35,17 +35,25 @@ public class HttpConfigurationListenerTests private ICertificateChainValidator certificateChainValidator; private ICertificateDtoConverter certificateDtoConverter; private ISystemProxyDetector proxySettingsDetector; - private TestLogger testLogger; + private ILogger logger; private HttpConfigurationListener testSubject; + private IServerCertificateInvalidNotification certificateInvalidNotification; [TestInitialize] public void TestInitialize() { - testLogger = new TestLogger(); + logger = Substitute.For(); + logger.ForContext(Arg.Any()).Returns(logger); certificateChainValidator = Substitute.For(); certificateDtoConverter = Substitute.For(); proxySettingsDetector = Substitute.For(); - testSubject = new HttpConfigurationListener(testLogger, certificateChainValidator, certificateDtoConverter, proxySettingsDetector); + certificateInvalidNotification = Substitute.For(); + + testSubject = new HttpConfigurationListener(logger, + certificateChainValidator, + certificateDtoConverter, + proxySettingsDetector, + certificateInvalidNotification); } [TestMethod] @@ -54,12 +62,16 @@ public void MefCtor_CheckIsExported() => MefTestHelpers.CreateExport(), MefTestHelpers.CreateExport(), MefTestHelpers.CreateExport(), - MefTestHelpers.CreateExport() + MefTestHelpers.CreateExport(), + MefTestHelpers.CreateExport() ); [TestMethod] public void Mef_CheckIsSingleton() => MefTestHelpers.CheckIsSingletonMefComponent(); + [TestMethod] + public void Ctor_LogContextIsSet() => logger.Received(1).ForContext(SLCoreStrings.SLCoreName, "Http"); + [TestMethod] [DataRow("htpp://localhost")] [DataRow("https://sonarcloud.io")] @@ -120,7 +132,7 @@ public async Task SelectProxiesAsync_UnknownProxyConfigured_ReturnsListWithHttpP var result = await testSubject.SelectProxiesAsync(parameter); result.proxies.Should().BeEquivalentTo([new ProxyDto(ProxyType.HTTP, SystemProxyHost, 1328)]); - testLogger.AssertOutputStringExists(string.Format(SLCoreStrings.UnknowProxyType, unknownScheme)); + logger.Received(1).WriteLine(SLCoreStrings.UnknowProxyType, unknownScheme); } [DataTestMethod] @@ -162,10 +174,34 @@ public async Task CheckServerTrustedAsync_Exception_ReturnsFalse() var primaryCertificateDto = new X509CertificateDto("some certificate"); var exceptionReason = "exception reason"; certificateDtoConverter.Convert(primaryCertificateDto).Throws(new ArgumentException(exceptionReason)); + var response = await testSubject.CheckServerTrustedAsync(new CheckServerTrustedParams([primaryCertificateDto], "ignored")); response.trusted.Should().Be(false); - testLogger.AssertPartialOutputStringExists(exceptionReason); + logger.Received(1).WriteLine(Arg.Is(x => x.Contains(exceptionReason))); + } + + [TestMethod] + public async Task CheckServerTrustedAsync_CertificateIsNotValid_ShowsNotification() + { + var (primaryCertificateDto, primaryCertificate) = SetUpCertificate("some certificate"); + certificateChainValidator.ValidateChain(primaryCertificate, Arg.Is>(x => !x.Any())).Returns(false); + + await testSubject.CheckServerTrustedAsync(new CheckServerTrustedParams([primaryCertificateDto], "ignored")); + + certificateInvalidNotification.Received(1).Show(); + } + + [TestMethod] + public async Task CheckServerTrustedAsync_CertificateIsValid_ClosesNotification() + { + var (primaryCertificateDto, primaryCertificate) = SetUpCertificate("some certificate"); + certificateChainValidator.ValidateChain(primaryCertificate, Arg.Is>(x => !x.Any())).Returns(true); + + await testSubject.CheckServerTrustedAsync(new CheckServerTrustedParams([primaryCertificateDto], "ignored")); + + certificateInvalidNotification.DidNotReceive().Show(); + certificateInvalidNotification.Received(1).Close(); } private (X509CertificateDto certificateDto, X509Certificate2 certificate) SetUpCertificate(string certificateName) @@ -181,4 +217,5 @@ public async Task CheckServerTrustedAsync_Exception_ReturnsFalse() private void MockProxyConfigured(string hostName, int port) => proxySettingsDetector.GetProxyUri(Arg.Any()).Returns(new Uri($"{hostName}:{port}")); private static string BuildUri(string scheme, string host) => $"{scheme}://{host}"; + } diff --git a/src/SLCore.Listeners.UnitTests/Implementation/Http/ServerCertificateInvalidNotificationTests.cs b/src/SLCore.Listeners.UnitTests/Implementation/Http/ServerCertificateInvalidNotificationTests.cs new file mode 100644 index 000000000..88d0d5985 --- /dev/null +++ b/src/SLCore.Listeners.UnitTests/Implementation/Http/ServerCertificateInvalidNotificationTests.cs @@ -0,0 +1,97 @@ +/* + * SonarLint for Visual Studio + * Copyright (C) 2016-2025 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +using SonarLint.VisualStudio.Core; +using SonarLint.VisualStudio.Core.Notifications; +using SonarLint.VisualStudio.SLCore.Listeners.Implementation.Http; + +namespace SonarLint.VisualStudio.SLCore.Listeners.UnitTests.Implementation.Http; + +[TestClass] +public class ServerCertificateInvalidNotificationTests +{ + private IBrowserService browserService; + private INotificationService notificationService; + private IOutputWindowService outputWindowService; + private ServerCertificateInvalidNotification testSubject; + + [TestInitialize] + public void TestInitialize() + { + notificationService = Substitute.For(); + browserService = Substitute.For(); + outputWindowService = Substitute.For(); + + testSubject = new ServerCertificateInvalidNotification( + notificationService, + outputWindowService, + browserService); + } + + [TestMethod] + public void MefCtor_CheckIsExported() => + MefTestHelpers.CheckTypeCanBeImported( + MefTestHelpers.CreateExport(), + MefTestHelpers.CreateExport(), + MefTestHelpers.CreateExport() + ); + + [TestMethod] + public void Mef_CheckIsSingleton() => MefTestHelpers.CheckIsSingletonMefComponent(); + + [TestMethod] + public void Show_ExpectedNotification() + { + testSubject.Show(); + + notificationService.Received(1).ShowNotification(Arg.Is(x => IsExpectedNotification(x))); + } + + [TestMethod] + public void Close_ClosesNotification() + { + testSubject.Close(); + + notificationService.Received(1).CloseNotification(); + } + + private bool IsExpectedNotification(INotification x) + { + VerifyNotificationHasExpectedActions(x); + + return x.Id == ServerCertificateInvalidNotification.ServerCertificateInvalidNotificationId + && x.Message == SLCoreStrings.ServerCertificateInfobar_CertificateInvalidMessage + && !x.ShowOncePerSession; + } + + private void VerifyNotificationHasExpectedActions(INotification notification) + { + notification.Actions.Should().HaveCount(2); + notification.Actions.Should().Contain(x => IsExpectedAction(x, SLCoreStrings.ServerCertificateInfobar_LearnMore)); + notification.Actions.Should().Contain(x => IsExpectedAction(x, SLCoreStrings.ServerCertificateInfobar_ShowLogs)); + + notification.Actions.First().Action.Invoke(null); + notification.Actions.Last().Action.Invoke(null); + browserService.Received(1).Navigate(DocumentationLinks.SslCertificate); + outputWindowService.Received(1).Show(); + } + + private static bool IsExpectedAction(INotificationAction notificationAction, string expectedText) => notificationAction.CommandText == expectedText && !notificationAction.ShouldDismissAfterAction; +} diff --git a/src/SLCore.Listeners/Implementation/Http/HttpConfigurationListener.cs b/src/SLCore.Listeners/Implementation/Http/HttpConfigurationListener.cs index 74652dd1e..19a66fb13 100644 --- a/src/SLCore.Listeners/Implementation/Http/HttpConfigurationListener.cs +++ b/src/SLCore.Listeners/Implementation/Http/HttpConfigurationListener.cs @@ -20,6 +20,7 @@ using System.ComponentModel.Composition; using SonarLint.VisualStudio.Core; +using SonarLint.VisualStudio.Core.Notifications; using SonarLint.VisualStudio.SLCore.Core; using SonarLint.VisualStudio.SLCore.Listener.Http; using SonarLint.VisualStudio.SLCore.Listener.Http.Model; @@ -34,15 +35,21 @@ internal class HttpConfigurationListener : IHttpConfigurationListener private readonly ICertificateChainValidator chainValidator; private readonly ICertificateDtoConverter certificateDtoConverter; private readonly ISystemProxyDetector proxySettingsDetector; + private readonly IServerCertificateInvalidNotification certificateInvalidNotification; private static readonly List socksHosts = ["socks4", "socks5"]; [ImportingConstructor] - public HttpConfigurationListener(ILogger logger, ICertificateChainValidator chainValidator, ICertificateDtoConverter certificateDtoConverter, ISystemProxyDetector proxySettingsDetector) + public HttpConfigurationListener(ILogger logger, + ICertificateChainValidator chainValidator, + ICertificateDtoConverter certificateDtoConverter, + ISystemProxyDetector proxySettingsDetector, + IServerCertificateInvalidNotification certificateInvalidNotification) { - this.logger = logger; + this.logger = logger.ForContext(SLCoreStrings.SLCoreName, "Http"); this.chainValidator = chainValidator; this.certificateDtoConverter = certificateDtoConverter; this.proxySettingsDetector = proxySettingsDetector; + this.certificateInvalidNotification = certificateInvalidNotification; } public Task SelectProxiesAsync(SelectProxiesParams parameters) @@ -84,11 +91,24 @@ public Task CheckServerTrustedAsync(CheckServerTrust { logger.WriteLine(SLCoreStrings.HttpConfiguration_ServerTrustVerificationRequest); var verificationResult = VerifyChain(parameters.chain); + ShowInvalidCertificateIfNeeded(verificationResult); logger.WriteLine(SLCoreStrings.HttpConfiguration_ServerTrustVerificationResult, verificationResult); return Task.FromResult(new CheckServerTrustedResponse(verificationResult)); } + private void ShowInvalidCertificateIfNeeded(bool verificationResult) + { + if (!verificationResult) + { + certificateInvalidNotification.Show(); + } + else + { + certificateInvalidNotification.Close(); + } + } + private bool VerifyChain(List chain) { try diff --git a/src/SLCore.Listeners/Implementation/Http/IServerCertificateInvalidNotification.cs b/src/SLCore.Listeners/Implementation/Http/IServerCertificateInvalidNotification.cs new file mode 100644 index 000000000..5b775a776 --- /dev/null +++ b/src/SLCore.Listeners/Implementation/Http/IServerCertificateInvalidNotification.cs @@ -0,0 +1,57 @@ +/* + * SonarLint for Visual Studio + * Copyright (C) 2016-2025 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +using System.ComponentModel.Composition; +using SonarLint.VisualStudio.Core; +using SonarLint.VisualStudio.Core.Notifications; + +namespace SonarLint.VisualStudio.SLCore.Listeners.Implementation.Http; + +internal interface IServerCertificateInvalidNotification +{ + void Show(); + + void Close(); +} + +[method: ImportingConstructor] +[Export(typeof(IServerCertificateInvalidNotification))] +[PartCreationPolicy(CreationPolicy.Shared)] +public class ServerCertificateInvalidNotification( + INotificationService notificationService, + IOutputWindowService outputWindowService, + IBrowserService browserService) + : IServerCertificateInvalidNotification +{ + internal const string ServerCertificateInvalidNotificationId = "ServerCertificateInvalidNotificationId"; + + public void Show() => notificationService.ShowNotification(GetServerCertificateInvalidNotification()); + + public void Close() => notificationService.CloseNotification(); + + private VisualStudio.Core.Notifications.Notification GetServerCertificateInvalidNotification() => + new(ServerCertificateInvalidNotificationId, + SLCoreStrings.ServerCertificateInfobar_CertificateInvalidMessage, + [ + new NotificationAction(SLCoreStrings.ServerCertificateInfobar_LearnMore, _ => browserService.Navigate(DocumentationLinks.SslCertificate), false), + new NotificationAction(SLCoreStrings.ServerCertificateInfobar_ShowLogs, _ => outputWindowService.Show(), false) + ], + showOncePerSession:false); +} diff --git a/src/SLCore/SLCoreStrings.Designer.cs b/src/SLCore/SLCoreStrings.Designer.cs index fe0e058e3..fd2d618c8 100644 --- a/src/SLCore/SLCoreStrings.Designer.cs +++ b/src/SLCore/SLCoreStrings.Designer.cs @@ -1,6 +1,7 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. +// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -123,7 +124,7 @@ public static string ConfigScopeNotInitialized { } /// - /// Looks up a localized string similar to [SLCore.Http] Received server trust verification request.... + /// Looks up a localized string similar to Received server trust verification request.... /// public static string HttpConfiguration_ServerTrustVerificationRequest { get { @@ -132,7 +133,7 @@ public static string HttpConfiguration_ServerTrustVerificationRequest { } /// - /// Looks up a localized string similar to [SLCore.Http] Server verification result: {0}. + /// Looks up a localized string similar to Server verification result: {0}. /// public static string HttpConfiguration_ServerTrustVerificationResult { get { @@ -149,6 +150,33 @@ public static string ModelExtensions_UnexpectedValue { } } + /// + /// Looks up a localized string similar to The server certificate can not be verified. Please see the logs for more info.. + /// + public static string ServerCertificateInfobar_CertificateInvalidMessage { + get { + return ResourceManager.GetString("ServerCertificateInfobar_CertificateInvalidMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Learn more. + /// + public static string ServerCertificateInfobar_LearnMore { + get { + return ResourceManager.GetString("ServerCertificateInfobar_LearnMore", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Show logs. + /// + public static string ServerCertificateInfobar_ShowLogs { + get { + return ResourceManager.GetString("ServerCertificateInfobar_ShowLogs", resourceCulture); + } + } + /// /// Looks up a localized string similar to Service Provider is unavailable. /// diff --git a/src/SLCore/SLCoreStrings.resx b/src/SLCore/SLCoreStrings.resx index 2759b7f49..f938f6833 100644 --- a/src/SLCore/SLCoreStrings.resx +++ b/src/SLCore/SLCoreStrings.resx @@ -163,10 +163,10 @@ Updated analysis readiness: {0} - [SLCore.Http] Received server trust verification request... + Received server trust verification request... - [SLCore.Http] Server verification result: {0} + Server verification result: {0} Unexpected server connection type @@ -186,4 +186,13 @@ SLCore + + The server certificate can not be verified. Please see the logs for more info. + + + Learn more + + + Show logs + \ No newline at end of file