diff --git a/src/ConnectedMode.UnitTests/Binding/UnintrusiveBindingControllerTests.cs b/src/ConnectedMode.UnitTests/Binding/UnintrusiveBindingControllerTests.cs index 2192a3228c..7d30e3699a 100644 --- a/src/ConnectedMode.UnitTests/Binding/UnintrusiveBindingControllerTests.cs +++ b/src/ConnectedMode.UnitTests/Binding/UnintrusiveBindingControllerTests.cs @@ -86,8 +86,8 @@ await sonarQubeService .Received() .ConnectAsync( Arg.Is(x => x.ServerUri.Equals("https://sonarcloud.io/") - && x.UserName.Equals(ValidToken.UserName) - && string.IsNullOrEmpty(x.Password.ToUnsecureString())), + && ((BasicAuthCredentials)x.Credentials).UserName.Equals(ValidToken.UserName) + && string.IsNullOrEmpty(((BasicAuthCredentials)x.Credentials).Password.ToUnsecureString())), ACancellationToken); } diff --git a/src/ConnectedMode.UnitTests/Persistence/BasicAuthCredentialsTests.cs b/src/ConnectedMode.UnitTests/Persistence/BasicAuthCredentialsTests.cs new file mode 100644 index 0000000000..01fc2d12ea --- /dev/null +++ b/src/ConnectedMode.UnitTests/Persistence/BasicAuthCredentialsTests.cs @@ -0,0 +1,72 @@ +/* + * SonarLint for Visual Studio + * Copyright (C) 2016-2024 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.ConnectedMode.Persistence; +using SonarLint.VisualStudio.TestInfrastructure; +using SonarQube.Client.Helpers; + +namespace SonarLint.VisualStudio.ConnectedMode.UnitTests.Persistence; + +[TestClass] +public class BasicAuthCredentialsTests +{ + private const string Username = "username"; + private const string Password = "pwd"; + + [TestMethod] + public void Ctor_WhenUsernameIsNull_ThrowsArgumentNullException() + { + Action act = () => new BasicAuthCredentials(null, Password.ToSecureString()); + + act.Should().Throw(); + } + + [TestMethod] + public void Ctor_WhenPasswordIsNull_ThrowsArgumentNullException() + { + Action act = () => new BasicAuthCredentials(Username, null); + + act.Should().Throw(); + } + + [TestMethod] + public void Dispose_DisposesPassword() + { + var testSubject = new BasicAuthCredentials(Username, Password.ToSecureString()); + + testSubject.Dispose(); + + Exceptions.Expect(() => testSubject.Password.ToUnsecureString()); + } + + [TestMethod] + public void Clone_ClonesPassword() + { + var password = "pwd"; + var testSubject = new BasicAuthCredentials(Username, password.ToSecureString()); + + var clone = (BasicAuthCredentials)testSubject.Clone(); + + clone.Should().NotBeSameAs(testSubject); + clone.Password.Should().NotBeSameAs(testSubject.Password); + clone.Password.ToUnsecureString().Should().Be(testSubject.Password.ToUnsecureString()); + clone.UserName.Should().Be(testSubject.UserName); + } +} diff --git a/src/ConnectedMode.UnitTests/Persistence/BoundSonarQubeProjectExtensionsTests.cs b/src/ConnectedMode.UnitTests/Persistence/BoundSonarQubeProjectExtensionsTests.cs index 00dcacdc8c..3a38310583 100644 --- a/src/ConnectedMode.UnitTests/Persistence/BoundSonarQubeProjectExtensionsTests.cs +++ b/src/ConnectedMode.UnitTests/Persistence/BoundSonarQubeProjectExtensionsTests.cs @@ -18,6 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +using Microsoft.VisualStudio.LanguageServices.Progression; using SonarLint.VisualStudio.ConnectedMode.Persistence; using SonarLint.VisualStudio.Core.Binding; using SonarLint.VisualStudio.TestInfrastructure; @@ -47,8 +48,7 @@ public void BoundSonarQubeProject_CreateConnectionInformation_NoCredentials() // Assert conn.ServerUri.Should().Be(input.ServerUri); - conn.UserName.Should().BeNull(); - conn.Password.Should().BeNull(); + conn.Credentials.Should().BeAssignableTo(); conn.Organization.Key.Should().Be("org_key"); conn.Organization.Name.Should().Be("org_name"); } @@ -66,8 +66,10 @@ public void BoundSonarQubeProject_CreateConnectionInformation_BasicAuthCredentia // Assert conn.ServerUri.Should().Be(input.ServerUri); - conn.UserName.Should().Be(creds.UserName); - conn.Password.ToUnsecureString().Should().Be(creds.Password.ToUnsecureString()); + var basicAuth = conn.Credentials as BasicAuthCredentials; + basicAuth.Should().NotBeNull(); + basicAuth.UserName.Should().Be(creds.UserName); + basicAuth.Password.ToUnsecureString().Should().Be(creds.Password.ToUnsecureString()); conn.Organization.Key.Should().Be("org_key"); conn.Organization.Name.Should().Be("org_name"); } @@ -83,8 +85,7 @@ public void BoundSonarQubeProject_CreateConnectionInformation_NoOrganizationNoAu // Assert conn.ServerUri.Should().Be(input.ServerUri); - conn.UserName.Should().BeNull(); - conn.Password.Should().BeNull(); + conn.Credentials.Should().BeAssignableTo(); conn.Organization.Should().BeNull(); } @@ -105,8 +106,7 @@ public void BoundServerProject_CreateConnectionInformation_NoCredentials() // Assert conn.ServerUri.Should().Be(input.ServerConnection.ServerUri); - conn.UserName.Should().BeNull(); - conn.Password.Should().BeNull(); + conn.Credentials.Should().BeAssignableTo(); conn.Organization.Key.Should().Be("org_key"); } @@ -123,8 +123,10 @@ public void BoundServerProject_CreateConnectionInformation_BasicAuthCredentials( // Assert conn.ServerUri.Should().Be(input.ServerConnection.ServerUri); - conn.UserName.Should().Be(creds.UserName); - conn.Password.ToUnsecureString().Should().Be(creds.Password.ToUnsecureString()); + var basicAuth = conn.Credentials as BasicAuthCredentials; + basicAuth.Should().NotBeNull(); + basicAuth.UserName.Should().Be(creds.UserName); + basicAuth.Password.ToUnsecureString().Should().Be(creds.Password.ToUnsecureString()); conn.Organization.Key.Should().Be("org_key"); } @@ -139,8 +141,7 @@ public void BoundServerProject_CreateConnectionInformation_NoOrganizationNoAuth( // Assert conn.ServerUri.Should().Be(input.ServerConnection.ServerUri); - conn.UserName.Should().BeNull(); - conn.Password.Should().BeNull(); + conn.Credentials.Should().BeAssignableTo(); conn.Organization.Should().BeNull(); } } diff --git a/src/ConnectedMode/Binding/IUnintrusiveBindingController.cs b/src/ConnectedMode/Binding/IUnintrusiveBindingController.cs index 96d2432899..f4df181622 100644 --- a/src/ConnectedMode/Binding/IUnintrusiveBindingController.cs +++ b/src/ConnectedMode/Binding/IUnintrusiveBindingController.cs @@ -21,6 +21,7 @@ using System.ComponentModel.Composition; using SonarLint.VisualStudio.Core.Binding; using SonarQube.Client; +using SonarQube.Client.Models; using Task = System.Threading.Tasks.Task; namespace SonarLint.VisualStudio.ConnectedMode.Binding @@ -57,7 +58,7 @@ public UnintrusiveBindingController(IBindingProcessFactory bindingProcessFactory public async Task BindAsync(BoundServerProject project, CancellationToken cancellationToken) { - var connectionInformation = project.ServerConnection.Credentials.CreateConnectionInformation(project.ServerConnection.ServerUri); + var connectionInformation = new ConnectionInformation(project.ServerConnection.ServerUri, project.ServerConnection.Credentials); await sonarQubeService.ConnectAsync(connectionInformation, cancellationToken); await BindAsync(project, null, cancellationToken); activeSolutionChangedHandler.HandleBindingChange(false); diff --git a/src/ConnectedMode/Persistence/BasicAuthCredentials.cs b/src/ConnectedMode/Persistence/BasicAuthCredentials.cs index d13d6255dc..927af48a6a 100644 --- a/src/ConnectedMode/Persistence/BasicAuthCredentials.cs +++ b/src/ConnectedMode/Persistence/BasicAuthCredentials.cs @@ -18,28 +18,20 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -using System; using System.Security; using SonarLint.VisualStudio.Core.Binding; +using SonarQube.Client.Helpers; using SonarQube.Client.Models; -namespace SonarLint.VisualStudio.ConnectedMode.Persistence +namespace SonarLint.VisualStudio.ConnectedMode.Persistence; + +internal sealed class BasicAuthCredentials(string userName, SecureString password) : ICredentials, IBasicAuthCredentials { - internal class BasicAuthCredentials : ICredentials - { - public BasicAuthCredentials(string userName, SecureString password) - { - this.UserName = userName; - this.Password = password; - } + public string UserName { get; } = userName ?? throw new ArgumentNullException(nameof(userName)); - public string UserName { get; } + public SecureString Password { get; } = password ?? throw new ArgumentNullException(nameof(password)); - public SecureString Password { get; } + public void Dispose() => Password?.Dispose(); - ConnectionInformation ICredentials.CreateConnectionInformation(Uri serverUri) - { - return new ConnectionInformation(serverUri, this.UserName, this.Password); - } - } + public object Clone() => new BasicAuthCredentials(UserName, Password.CopyAsReadOnly()); } diff --git a/src/ConnectedMode/Persistence/ConnectionInfoConverter.cs b/src/ConnectedMode/Persistence/ConnectionInfoConverter.cs deleted file mode 100644 index 8162240004..0000000000 --- a/src/ConnectedMode/Persistence/ConnectionInfoConverter.cs +++ /dev/null @@ -1,38 +0,0 @@ -/* - * SonarLint for Visual Studio - * Copyright (C) 2016-2024 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.Diagnostics.CodeAnalysis; -using SonarLint.VisualStudio.Core.Binding; -using SonarQube.Client.Models; - -namespace SonarLint.VisualStudio.ConnectedMode.Persistence; - -[ExcludeFromCodeCoverage] // todo remove https://sonarsource.atlassian.net/browse/SLVS-1408 -public static class ConnectionInfoConverter -{ - public static ServerConnection ToServerConnection(this ConnectionInformation connectionInformation) => - connectionInformation switch - { - { Organization.Key: { } organization } => new ServerConnection.SonarCloud(organization, - credentials: new BasicAuthCredentials(connectionInformation.UserName, connectionInformation.Password)), - _ => new ServerConnection.SonarQube(connectionInformation.ServerUri, - credentials: new BasicAuthCredentials(connectionInformation.UserName, connectionInformation.Password)) - }; -} diff --git a/src/Core/Binding/BoundSonarQubeProjectExtensions.cs b/src/Core/Binding/BoundSonarQubeProjectExtensions.cs index abbcc63204..af90addb72 100644 --- a/src/Core/Binding/BoundSonarQubeProjectExtensions.cs +++ b/src/Core/Binding/BoundSonarQubeProjectExtensions.cs @@ -32,9 +32,7 @@ public static ConnectionInformation CreateConnectionInformation(this BoundSonarQ throw new ArgumentNullException(nameof(binding)); } - var connection = binding.Credentials == null ? - new ConnectionInformation(binding.ServerUri) - : binding.Credentials.CreateConnectionInformation(binding.ServerUri); + var connection = new ConnectionInformation(binding.ServerUri, binding.Credentials); connection.Organization = binding.Organization; return connection; @@ -47,9 +45,7 @@ public static ConnectionInformation CreateConnectionInformation(this BoundServer throw new ArgumentNullException(nameof(binding)); } - var connection = binding.ServerConnection.Credentials == null ? - new ConnectionInformation(binding.ServerConnection.ServerUri) - : binding.ServerConnection.Credentials.CreateConnectionInformation(binding.ServerConnection.ServerUri); + var connection = new ConnectionInformation(binding.ServerConnection.ServerUri, binding.ServerConnection.Credentials); connection.Organization = binding.ServerConnection is ServerConnection.SonarCloud sc ? new SonarQubeOrganization(sc.OrganizationKey, null) : null; return connection; diff --git a/src/Core/Binding/ICredentials.cs b/src/Core/Binding/ICredentials.cs index 4a488970cd..d25430a114 100644 --- a/src/Core/Binding/ICredentials.cs +++ b/src/Core/Binding/ICredentials.cs @@ -18,13 +18,11 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -using System; using SonarQube.Client.Models; namespace SonarLint.VisualStudio.Core.Binding { - public interface ICredentials + public interface ICredentials : IConnectionCredentials { - ConnectionInformation CreateConnectionInformation(Uri serverUri); } } diff --git a/src/Integration.UnitTests/Service/ConnectionInformationTests.cs b/src/Integration.UnitTests/Service/ConnectionInformationTests.cs index c4ccb8625f..c03032f070 100644 --- a/src/Integration.UnitTests/Service/ConnectionInformationTests.cs +++ b/src/Integration.UnitTests/Service/ConnectionInformationTests.cs @@ -21,6 +21,7 @@ using System; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; +using SonarLint.VisualStudio.ConnectedMode.Persistence; using SonarQube.Client.Helpers; using SonarQube.Client.Models; using SonarLint.VisualStudio.TestInfrastructure; @@ -38,14 +39,15 @@ public void ConnectionInformation_WithLoginInformation() var passwordUnsecure = "admin"; var password = passwordUnsecure.ToSecureString(); var serverUri = new Uri("http://localhost/"); - var testSubject = new ConnectionInformation(serverUri, userName, password); + var credentials = new BasicAuthCredentials(userName, password); + var testSubject = new ConnectionInformation(serverUri, credentials); // Act password.Dispose(); // Connection information should maintain it's own copy of the password // Assert - testSubject.Password.ToUnsecureString().Should().Be(passwordUnsecure, "Password doesn't match"); - testSubject.UserName.Should().Be(userName, "UserName doesn't match"); + ((BasicAuthCredentials)testSubject.Credentials).Password.ToUnsecureString().Should().Be(passwordUnsecure, "Password doesn't match"); + ((BasicAuthCredentials)testSubject.Credentials).UserName.Should().Be(userName, "UserName doesn't match"); testSubject.ServerUri.Should().Be(serverUri, "ServerUri doesn't match"); // Act clone @@ -55,11 +57,11 @@ public void ConnectionInformation_WithLoginInformation() testSubject.Dispose(); // Assert testSubject - Exceptions.Expect(() => testSubject.Password.ToUnsecureString()); + Exceptions.Expect(() => ((BasicAuthCredentials)testSubject.Credentials).Password.ToUnsecureString()); // Assert testSubject2 - testSubject2.Password.ToUnsecureString().Should().Be(passwordUnsecure, "Password doesn't match"); - testSubject2.UserName.Should().Be(userName, "UserName doesn't match"); + ((BasicAuthCredentials)testSubject2.Credentials).Password.ToUnsecureString().Should().Be(passwordUnsecure, "Password doesn't match"); + ((BasicAuthCredentials)testSubject.Credentials).UserName.Should().Be(userName, "UserName doesn't match"); testSubject2.ServerUri.Should().Be(serverUri, "ServerUri doesn't match"); } @@ -70,19 +72,17 @@ public void ConnectionInformation_WithoutLoginInformation() var serverUri = new Uri("http://localhost/"); // Act - var testSubject = new ConnectionInformation(serverUri); + var testSubject = new ConnectionInformation(serverUri, null); // Assert - testSubject.Password.Should().BeNull("Password wasn't provided"); - testSubject.UserName.Should().BeNull("UserName wasn't provided"); + testSubject.Credentials.Should().BeAssignableTo(); testSubject.ServerUri.Should().Be(serverUri, "ServerUri doesn't match"); // Act clone var testSubject2 = (ConnectionInformation)((ICloneable)testSubject).Clone(); // Assert testSubject2 - testSubject2.Password.Should().BeNull("Password wasn't provided"); - testSubject2.UserName.Should().BeNull("UserName wasn't provided"); + testSubject2.Credentials.Should().BeAssignableTo(); testSubject2.ServerUri.Should().Be(serverUri, "ServerUri doesn't match"); } @@ -110,7 +110,7 @@ public void ConnectionInformation_Ctor_FixesSonarCloudUri() public void ConnectionInformation_Ctor_ArgChecks() { Exceptions.Expect(() => new ConnectionInformation(null)); - Exceptions.Expect(() => new ConnectionInformation(null, "user", "pwd".ToSecureString())); + Exceptions.Expect(() => new ConnectionInformation(null, new BasicAuthCredentials("user", "pwd".ToSecureString()))); } } } diff --git a/src/SonarQube.Client.Tests/CallRealServerTestHarness.cs b/src/SonarQube.Client.Tests/CallRealServerTestHarness.cs index a3e6c116a5..d4bc0f4f04 100644 --- a/src/SonarQube.Client.Tests/CallRealServerTestHarness.cs +++ b/src/SonarQube.Client.Tests/CallRealServerTestHarness.cs @@ -18,13 +18,8 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -using System; using System.Net.Http; using System.Security; -using System.Threading; -using System.Threading.Tasks; -using FluentAssertions; -using Microsoft.VisualStudio.TestTools.UnitTesting; using SonarQube.Client.Models; using SonarQube.Client.Tests.Infra; @@ -53,7 +48,7 @@ public async Task Call_Real_SonarQube() password.AppendChar('i'); password.AppendChar('n'); - var connInfo = new ConnectionInformation(url, userName, password); + var connInfo = new ConnectionInformation(url, MockBasicAuthCredentials(userName, password)); var service = new SonarQubeService(new HttpClientHandler(), "agent", new TestLogger()); try @@ -79,7 +74,7 @@ public async Task Call_Real_SonarCloud() var url = ConnectionInformation.FixedSonarCloudUri; var password = new SecureString(); - var connInfo = new ConnectionInformation(url, validSonarCloudToken, password); + var connInfo = new ConnectionInformation(url, MockBasicAuthCredentials(validSonarCloudToken, password)); var service = new SonarQubeService(new HttpClientHandler(), "agent", new TestLogger()); try @@ -96,5 +91,13 @@ public async Task Call_Real_SonarCloud() service.Disconnect(); } } + + private static IBasicAuthCredentials MockBasicAuthCredentials(string userName, SecureString password) + { + var mock = Substitute.For(); + mock.UserName.Returns(userName); + mock.Password.Returns(password); + return mock; + } } } diff --git a/src/SonarQube.Client.Tests/Helpers/AuthenticationHeaderFactoryTests.cs b/src/SonarQube.Client.Tests/Helpers/AuthenticationHeaderFactoryTests.cs index 723c6d23af..81a2919777 100644 --- a/src/SonarQube.Client.Tests/Helpers/AuthenticationHeaderFactoryTests.cs +++ b/src/SonarQube.Client.Tests/Helpers/AuthenticationHeaderFactoryTests.cs @@ -18,18 +18,20 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -using System; -using FluentAssertions; -using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Security; using SonarQube.Client.Helpers; +using SonarQube.Client.Models; namespace SonarQube.Client.Tests.Helpers { [TestClass] public class AuthenticationHeaderFactoryTests { + private const string Password = "password"; + private const string Username = "username"; + [TestMethod] - public void AuthenticationHeaderHelper_GetAuthToken() + public void GetAuthToken_ReturnsExpectedString() { // Invalid input string user = "hello:"; @@ -59,8 +61,48 @@ public void AuthenticationHeaderHelper_GetAuthToken() AuthenticationHeaderFactory.GetBasicAuthToken(user, password.ToSecureString())); } + [TestMethod] + public void Create_NoCredentials_ReturnsNull() + { + var authenticationHeaderValue = AuthenticationHeaderFactory.Create(new NoCredentials()); + + authenticationHeaderValue.Should().BeNull(); + } + + [TestMethod] + public void Create_UnsupportedAuthentication_ReturnsNull() + { + using var scope = new AssertIgnoreScope(); + + var authenticationHeaderValue = AuthenticationHeaderFactory.Create(null); + + authenticationHeaderValue.Should().BeNull(); + } + + [TestMethod] + public void Create_BasicAuth_UsernameIsNull_Throws() + { + var credentials = MockBasicAuthCredentials(null, Password.ToSecureString()); + + var act = () => AuthenticationHeaderFactory.Create(credentials); + + act.Should().Throw(); + } + + [TestMethod] + public void Create_BasicAuth_CredentialsProvided_ReturnsBasicScheme() + { + var credentials = MockBasicAuthCredentials(Username, Password.ToSecureString()); + + var authenticationHeaderValue = AuthenticationHeaderFactory.Create(credentials); + + authenticationHeaderValue.Scheme.Should().Be("Basic"); + AssertAreEqualUserNameAndPassword(Username, Password, authenticationHeaderValue.Parameter); + } + private void AssertAreEqualUserNameAndPassword(string expectedUser, string expectedPassword, string userAndPasswordBase64String) + { string userNameAndPassword = AuthenticationHeaderFactory.BasicAuthEncoding.GetString(Convert.FromBase64String(userAndPasswordBase64String)); @@ -76,5 +118,13 @@ private void AssertAreEqualUserNameAndPassword(string expectedUser, string expec userNameAndPasswordTokens.Should().HaveElementAt(0, expectedUser); userNameAndPasswordTokens.Should().HaveElementAt(1, expectedPassword); } + + private static IBasicAuthCredentials MockBasicAuthCredentials(string userName, SecureString password) + { + var mock = Substitute.For(); + mock.UserName.Returns(userName); + mock.Password.Returns(password); + return mock; + } } } diff --git a/src/SonarQube.Client.Tests/Models/ConnectionInformationTests.cs b/src/SonarQube.Client.Tests/Models/ConnectionInformationTests.cs index 07df37d347..7d5e4d36ed 100644 --- a/src/SonarQube.Client.Tests/Models/ConnectionInformationTests.cs +++ b/src/SonarQube.Client.Tests/Models/ConnectionInformationTests.cs @@ -18,10 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -using System; using System.Security; -using FluentAssertions; -using Microsoft.VisualStudio.TestTools.UnitTesting; using SonarQube.Client.Helpers; using SonarQube.Client.Models; @@ -54,14 +51,14 @@ public void Ctor_SonarQubeUrl_IsProcessedCorrectly(string inputUrl, string expec } [TestMethod] - [DataRow("http://sonarcloud.io") ] - [DataRow("http://sonarcloud.io/") ] - [DataRow("https://sonarcloud.io") ] - [DataRow("https://sonarcloud.io/") ] - [DataRow("http://SONARCLOUD.IO") ] - [DataRow("http://www.sonarcloud.io") ] - [DataRow("https://www.sonarcloud.io/") ] - [DataRow("http://sonarcloud.io:9999") ] + [DataRow("http://sonarcloud.io")] + [DataRow("http://sonarcloud.io/")] + [DataRow("https://sonarcloud.io")] + [DataRow("https://sonarcloud.io/")] + [DataRow("http://SONARCLOUD.IO")] + [DataRow("http://www.sonarcloud.io")] + [DataRow("https://www.sonarcloud.io/")] + [DataRow("http://sonarcloud.io:9999")] public void Ctor_SonarCloudUrl_IsProcessedCorrectly(string inputUrl) { var testSubject = new ConnectionInformation(new Uri(inputUrl)); @@ -80,8 +77,9 @@ public void Clone_PropertiesAreCopiedCorrectly(string serverUrl, string userName { var securePwd = InitializeSecureString(password); var org = InitializeOrganization(orgKey); + var credentials = MockBasicAuthCredentials(userName, securePwd); - var testSubject = new ConnectionInformation(new Uri(serverUrl), userName, securePwd) + var testSubject = new ConnectionInformation(new Uri(serverUrl), credentials) { Organization = org }; @@ -90,20 +88,20 @@ public void Clone_PropertiesAreCopiedCorrectly(string serverUrl, string userName cloneObj.Should().BeOfType(); CheckPropertiesMatch(testSubject, (ConnectionInformation)cloneObj); + _= credentials.Received().Clone(); } [TestMethod] public void Dispose_PasswordIsDisposed() { var pwd = "secret".ToSecureString(); - var testSubject = new ConnectionInformation(new Uri("http://any"), "any", pwd); + var credentials = MockBasicAuthCredentials("any", pwd); + var testSubject = new ConnectionInformation(new Uri("http://any"), credentials); testSubject.Dispose(); testSubject.IsDisposed.Should().BeTrue(); - - Action accessPassword = () => _ = testSubject.Password.Length; - accessPassword.Should().ThrowExactly(); + credentials.Received(1).Dispose(); } private static SecureString InitializeSecureString(string password) => @@ -116,21 +114,32 @@ private static SonarQubeOrganization InitializeOrganization(string orgKey) => private static void CheckPropertiesMatch(ConnectionInformation item1, ConnectionInformation item2) { item1.ServerUri.Should().Be(item2.ServerUri); - item1.UserName.Should().Be(item2.UserName); - item1.Organization.Should().Be(item2.Organization); + var credentials1 = (IBasicAuthCredentials)item1.Credentials; + var credentials2 = (IBasicAuthCredentials)item2.Credentials; + + credentials1.UserName.Should().Be(credentials2.UserName); + item1.Organization.Should().Be(item2.Organization); - if (item1.Password == null) + if (credentials1.Password == null) { - item2.Password.Should().BeNull(); + credentials2.Password.Should().BeNull(); } else { - item1.Password.ToUnsecureString().Should().Be(item2.Password.ToUnsecureString()); + credentials1.Password.ToUnsecureString().Should().Be(credentials2.Password.ToUnsecureString()); } - item1.Authentication.Should().Be(item2.Authentication); item1.IsSonarCloud.Should().Be(item2.IsSonarCloud); } + + private static IBasicAuthCredentials MockBasicAuthCredentials(string userName, SecureString password) + { + var mock = Substitute.For(); + mock.UserName.Returns(userName); + mock.Password.Returns(password); + mock.Clone().Returns(mock); + return mock; + } } } diff --git a/src/SonarQube.Client.Tests/SonarQubeService_Lifecycle.cs b/src/SonarQube.Client.Tests/SonarQubeService_Lifecycle.cs index 187663f918..26687598d6 100644 --- a/src/SonarQube.Client.Tests/SonarQubeService_Lifecycle.cs +++ b/src/SonarQube.Client.Tests/SonarQubeService_Lifecycle.cs @@ -18,13 +18,8 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -using System; -using System.Threading; -using System.Threading.Tasks; -using FluentAssertions; -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; using Moq.Protected; -using SonarQube.Client.Helpers; using SonarQube.Client.Models; namespace SonarQube.Client.Tests @@ -43,7 +38,7 @@ public async Task Connect_To_SonarQube_Valid_Credentials() service.GetServerInfo().Should().BeNull(); await service.ConnectAsync( - new ConnectionInformation(new Uri("http://localhost"), "user", "pass".ToSecureString()), + new ConnectionInformation(new Uri("http://localhost"), Mock.Of()), CancellationToken.None); service.IsConnected.Should().BeTrue(); @@ -61,7 +56,7 @@ public async Task Connect_To_SonarQube_Invalid_Credentials() service.GetServerInfo().Should().BeNull(); Func act = () => service.ConnectAsync( - new ConnectionInformation(new Uri("http://localhost"), "user", "pass".ToSecureString()), + new ConnectionInformation(new Uri("http://localhost"), Mock.Of()), CancellationToken.None); var ex = await act.Should().ThrowAsync(); @@ -83,7 +78,7 @@ public async Task Connect_ServerIsNotReachable_IsConnectedIsFalse() service.GetServerInfo().Should().BeNull(); Func act = () => service.ConnectAsync( - new ConnectionInformation(new Uri("http://localhost"), "user", "pass".ToSecureString()), + new ConnectionInformation(new Uri("http://localhost"), Mock.Of()), CancellationToken.None); var ex = await act.Should().ThrowAsync(); @@ -106,7 +101,7 @@ public async Task Connect_SonarQube_IsSonarCloud_SonarQubeUrl_ReturnsFalse(strin SetupRequest("api/authentication/validate", "{ \"valid\": true }", serverUrl: canonicalUrl); await service.ConnectAsync( - new ConnectionInformation(new Uri(inputUrl), "user", "pass".ToSecureString()), + new ConnectionInformation(new Uri(inputUrl), Mock.Of()), CancellationToken.None); service.GetServerInfo().ServerType.Should().Be(ServerType.SonarQube); @@ -126,7 +121,7 @@ public async Task Connect_SonarQube_IsSonarCloud_SonarCloud_ReturnTrue(string in SetupRequest("api/authentication/validate", "{ \"valid\": true }", serverUrl: fixedSonarCloudUrl); await service.ConnectAsync( - new ConnectionInformation(new Uri(inputUrl), "user", "pass".ToSecureString()), + new ConnectionInformation(new Uri(inputUrl), Mock.Of()), CancellationToken.None); service.GetServerInfo().ServerType.Should().Be(ServerType.SonarCloud); diff --git a/src/SonarQube.Client.Tests/SonarQubeService_TestBase.cs b/src/SonarQube.Client.Tests/SonarQubeService_TestBase.cs index d23b649a73..bf63208f8e 100644 --- a/src/SonarQube.Client.Tests/SonarQubeService_TestBase.cs +++ b/src/SonarQube.Client.Tests/SonarQubeService_TestBase.cs @@ -18,16 +18,11 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -using System; using System.Globalization; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Security; -using System.Threading; -using System.Threading.Tasks; -using FluentAssertions; -using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using Moq.Protected; using SonarQube.Client.Helpers; @@ -94,7 +89,7 @@ protected async Task ConnectToSonarQube(string version = "5.6.0.0", string serve SetupRequest("api/authentication/validate", "{ \"valid\": true}", serverUrl: serverUrl); await service.ConnectAsync( - new ConnectionInformation(new Uri(serverUrl), "valeri", new SecureString()), + new ConnectionInformation(new Uri(serverUrl), MockBasicAuthCredentials("valeri", new SecureString())), CancellationToken.None); // Sanity checks @@ -120,5 +115,14 @@ protected internal virtual SonarQubeService CreateTestSubject() { return new SonarQubeService(messageHandler.Object, UserAgent, logger, requestFactorySelector, secondaryIssueHashUpdater.Object, sseStreamFactory.Object); } + + private static IBasicAuthCredentials MockBasicAuthCredentials(string userName, SecureString password) + { + var mock = new Mock(); + mock.SetupGet(x => x.UserName).Returns(userName); + mock.SetupGet(x => x.Password).Returns(password); + + return mock.Object; + } } } diff --git a/src/SonarQube.Client/Helpers/AuthenticationHeaderFactory.cs b/src/SonarQube.Client/Helpers/AuthenticationHeaderFactory.cs index 5f92668a9a..f5637c728a 100644 --- a/src/SonarQube.Client/Helpers/AuthenticationHeaderFactory.cs +++ b/src/SonarQube.Client/Helpers/AuthenticationHeaderFactory.cs @@ -18,8 +18,6 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -using System; -using System.Diagnostics; using System.Net.Http.Headers; using System.Security; using System.Text; @@ -36,20 +34,20 @@ public static class AuthenticationHeaderFactory /// internal static readonly Encoding BasicAuthEncoding = Encoding.UTF8; - public static AuthenticationHeaderValue Create(string userName, SecureString password, AuthenticationType authentication) + public static AuthenticationHeaderValue Create(IConnectionCredentials credentials) { - if (authentication == AuthenticationType.Basic) + if (credentials is IBasicAuthCredentials basicAuthCredentials) { - return string.IsNullOrWhiteSpace(userName) - ? null - : new AuthenticationHeaderValue("Basic", GetBasicAuthToken(userName, password)); + ValidateCredentials(basicAuthCredentials); + return new AuthenticationHeaderValue("Basic", GetBasicAuthToken(basicAuthCredentials.UserName, basicAuthCredentials.Password)); // See more info: https://www.visualstudio.com/en-us/integrate/get-started/auth/overview } - else + if (credentials is INoCredentials) { - Debug.Fail("Unsupported Authentication: " + authentication); return null; } + Debug.Fail("Unsupported Authentication: " + credentials?.GetType()); + return null; } internal static string GetBasicAuthToken(string user, SecureString password) @@ -64,5 +62,13 @@ internal static string GetBasicAuthToken(string user, SecureString password) return Convert.ToBase64String(BasicAuthEncoding.GetBytes(string.Join(BasicAuthCredentialSeparator, user, password.ToUnsecureString()))); } + + private static void ValidateCredentials(IBasicAuthCredentials basicAuthCredentials) + { + if (string.IsNullOrEmpty(basicAuthCredentials.UserName)) + { + throw new ArgumentException(nameof(basicAuthCredentials.UserName)); + } + } } } diff --git a/src/SonarQube.Client/Models/ConnectionInformation.cs b/src/SonarQube.Client/Models/ConnectionInformation.cs index 9eb320a09c..2dafca49e8 100644 --- a/src/SonarQube.Client/Models/ConnectionInformation.cs +++ b/src/SonarQube.Client/Models/ConnectionInformation.cs @@ -18,8 +18,6 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -using System; -using System.Security; using SonarQube.Client.Helpers; namespace SonarQube.Client.Models @@ -35,7 +33,7 @@ public sealed class ConnectionInformation : ICloneable, IDisposable private bool isDisposed; - public ConnectionInformation(Uri serverUri, string userName, SecureString password) + public ConnectionInformation(Uri serverUri, IConnectionCredentials credentials) { if (serverUri == null) { @@ -43,14 +41,12 @@ public ConnectionInformation(Uri serverUri, string userName, SecureString passwo } ServerUri = FixSonarCloudUri(serverUri).EnsureTrailingSlash(); - UserName = userName; - Password = password?.CopyAsReadOnly(); - Authentication = AuthenticationType.Basic; // Only one supported at this point + Credentials = (IConnectionCredentials)credentials?.Clone() ?? new NoCredentials(); IsSonarCloud = ServerUri == FixedSonarCloudUri; } public ConnectionInformation(Uri serverUri) - : this(serverUri, null, null) + : this(serverUri, null) { } @@ -58,11 +54,7 @@ public ConnectionInformation(Uri serverUri) public bool IsSonarCloud { get; } - public string UserName { get; } - - public SecureString Password { get; } - - public AuthenticationType Authentication { get; } + public IConnectionCredentials Credentials { get; } public bool IsDisposed => isDisposed; @@ -70,7 +62,7 @@ public ConnectionInformation(Uri serverUri) public ConnectionInformation Clone() { - return new ConnectionInformation(ServerUri, UserName, Password?.CopyAsReadOnly()) { Organization = Organization }; + return new ConnectionInformation(ServerUri, (IConnectionCredentials)Credentials?.Clone()) { Organization = Organization }; } object ICloneable.Clone() @@ -96,7 +88,7 @@ public void Dispose() { if (!isDisposed) { - Password?.Dispose(); + Credentials?.Dispose(); isDisposed = true; } } diff --git a/src/SonarQube.Client/Models/IConnectionCredentials.cs b/src/SonarQube.Client/Models/IConnectionCredentials.cs new file mode 100644 index 0000000000..88cbe512ad --- /dev/null +++ b/src/SonarQube.Client/Models/IConnectionCredentials.cs @@ -0,0 +1,37 @@ +/* + * SonarLint for Visual Studio + * Copyright (C) 2016-2024 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.Security; + +namespace SonarQube.Client.Models; + +public interface IConnectionCredentials : IDisposable, ICloneable +{ +} + +public interface IBasicAuthCredentials : IConnectionCredentials +{ + public string UserName { get; } + public SecureString Password { get; } +} + +public interface INoCredentials : IConnectionCredentials +{ +} diff --git a/src/SonarQube.Client/Models/AuthenticationType.cs b/src/SonarQube.Client/Models/NoCredentials.cs similarity index 83% rename from src/SonarQube.Client/Models/AuthenticationType.cs rename to src/SonarQube.Client/Models/NoCredentials.cs index acc6c4a0a3..fdae5285d4 100644 --- a/src/SonarQube.Client/Models/AuthenticationType.cs +++ b/src/SonarQube.Client/Models/NoCredentials.cs @@ -18,7 +18,11 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -namespace SonarQube.Client.Models +namespace SonarQube.Client.Models; + +internal sealed class NoCredentials : INoCredentials { - public enum AuthenticationType { Basic } + public void Dispose() { } + + public object Clone() => new NoCredentials(); } diff --git a/src/SonarQube.Client/SonarQubeService.cs b/src/SonarQube.Client/SonarQubeService.cs index 35617bf6fd..63a3f72764 100644 --- a/src/SonarQube.Client/SonarQubeService.cs +++ b/src/SonarQube.Client/SonarQubeService.cs @@ -144,8 +144,7 @@ public async Task ConnectAsync(ConnectionInformation connection, CancellationTok BaseAddress = connection.ServerUri, DefaultRequestHeaders = { - Authorization = AuthenticationHeaderFactory.Create( - connection.UserName, connection.Password, connection.Authentication), + Authorization = AuthenticationHeaderFactory.Create(connection.Credentials), }, };