diff --git a/src/ConnectedMode.UnitTests/Binding/UnintrusiveBindingControllerTests.cs b/src/ConnectedMode.UnitTests/Binding/UnintrusiveBindingControllerTests.cs index 2192a3228c..ed404f5949 100644 --- a/src/ConnectedMode.UnitTests/Binding/UnintrusiveBindingControllerTests.cs +++ b/src/ConnectedMode.UnitTests/Binding/UnintrusiveBindingControllerTests.cs @@ -34,7 +34,7 @@ namespace SonarLint.VisualStudio.ConnectedMode.UnitTests.Binding; public class UnintrusiveBindingControllerTests { private static readonly CancellationToken ACancellationToken = CancellationToken.None; - private static readonly BasicAuthCredentials ValidToken = new("TOKEN", new SecureString()); + private static readonly UsernameAndPasswordCredentials ValidToken = new("TOKEN", new SecureString()); private static readonly BoundServerProject AnyBoundProject = new("any", "any", new ServerConnection.SonarCloud("any", credentials: ValidToken)); private IActiveSolutionChangedHandler activeSolutionChangedHandler; private IBindingProcess bindingProcess; @@ -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())), + && ((UsernameAndPasswordCredentials)x.Credentials).UserName.Equals(ValidToken.UserName) + && string.IsNullOrEmpty(((UsernameAndPasswordCredentials)x.Credentials).Password.ToUnsecureString())), ACancellationToken); } diff --git a/src/ConnectedMode.UnitTests/Migration/BindingToConnectionMigrationTests.cs b/src/ConnectedMode.UnitTests/Migration/BindingToConnectionMigrationTests.cs index 1ff381840a..403b826e0a 100644 --- a/src/ConnectedMode.UnitTests/Migration/BindingToConnectionMigrationTests.cs +++ b/src/ConnectedMode.UnitTests/Migration/BindingToConnectionMigrationTests.cs @@ -151,7 +151,6 @@ public async Task MigrateBindingToServerConnectionIfNeeded_MigrationIsExecutedFo var boundProjects = bindingPathToBoundProjectDictionary.Values.ToList(); var expectedServerConnectionId = boundProjects[0].ServerUri.ToString(); serverConnectionsRepository.TryGet(expectedServerConnectionId, out _).Returns(true); - await testSubject.MigrateAllBindingsToServerConnectionsIfNeededAsync(); @@ -215,8 +214,7 @@ private Dictionary CreateTwoBindingPathsToMockedB { Dictionary pathToBindings = new() { - {"bindings/proj1/binding.config", CreateBoundProject("http://server1", "proj1")}, - {"bindings/proj2/binding.config", CreateBoundProject("http://server2", "proj2")} + { "bindings/proj1/binding.config", CreateBoundProject("http://server1", "proj1") }, { "bindings/proj2/binding.config", CreateBoundProject("http://server2", "proj2") } }; unintrusiveBindingPathProvider.GetBindingPaths().Returns(pathToBindings.Select(kvp => kvp.Key)); foreach (var kvp in pathToBindings) @@ -234,7 +232,7 @@ private void MockValidBinding(string bindingPath, BoundSonarQubeProject sonarQub private static BoundSonarQubeProject CreateBoundProject(string url, string projectKey) { - return new BoundSonarQubeProject(new Uri(url), projectKey, "projectName", credentials: new BasicAuthCredentials("admin", "admin".ToSecureString())); + return new BoundSonarQubeProject(new Uri(url), projectKey, "projectName", credentials: new UsernameAndPasswordCredentials("admin", "admin".ToSecureString())); } private static bool IsExpectedServerConnection(ServerConnection serverConnection, BoundSonarQubeProject boundProject) diff --git a/src/ConnectedMode.UnitTests/Persistence/BindingJsonModelConverterTests.cs b/src/ConnectedMode.UnitTests/Persistence/BindingJsonModelConverterTests.cs index c329050866..e5b3c7be7c 100644 --- a/src/ConnectedMode.UnitTests/Persistence/BindingJsonModelConverterTests.cs +++ b/src/ConnectedMode.UnitTests/Persistence/BindingJsonModelConverterTests.cs @@ -48,7 +48,7 @@ public void MefCtor_CheckIsSingleton() { MefTestHelpers.CheckIsSingletonMefComponent(); } - + [TestMethod] public void ConvertFromModel_ConvertsCorrectly() { @@ -75,10 +75,7 @@ public void ConvertFromModel_ConvertsCorrectly() [TestMethod] public void ConvertToModel_SonarCloudConnection_ConvertsCorrectly() { - var boundServerProject = new BoundServerProject("localBinding", "serverProject", new ServerConnection.SonarCloud("myorg")) - { - Profiles = new Dictionary() - }; + var boundServerProject = new BoundServerProject("localBinding", "serverProject", new ServerConnection.SonarCloud("myorg")) { Profiles = new Dictionary() }; var bindingModel = testSubject.ConvertToModel(boundServerProject); @@ -89,7 +86,7 @@ public void ConvertToModel_SonarCloudConnection_ConvertsCorrectly() bindingModel.ServerConnectionId.Should().BeSameAs(boundServerProject.ServerConnection.Id); bindingModel.Profiles.Should().BeSameAs(boundServerProject.Profiles); } - + [TestMethod] public void ConvertToModel_SonarQubeConnection_ConvertsCorrectly() { @@ -111,7 +108,7 @@ public void ConvertToModel_SonarQubeConnection_ConvertsCorrectly() [TestMethod] public void ConvertFromModelToLegacy_ConvertsCorrectly() { - var credentials = Substitute.For(); + var credentials = Substitute.For(); var bindingModel = new BindingJsonModel { Organization = new SonarQubeOrganization("org", "my org"), diff --git a/src/ConnectedMode.UnitTests/Persistence/BoundSonarQubeProjectExtensionsTests.cs b/src/ConnectedMode.UnitTests/Persistence/BoundSonarQubeProjectExtensionsTests.cs index 00dcacdc8c..a37394851e 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"); } @@ -57,7 +57,7 @@ public void BoundSonarQubeProject_CreateConnectionInformation_NoCredentials() public void BoundSonarQubeProject_CreateConnectionInformation_BasicAuthCredentials() { // Arrange - var creds = new BasicAuthCredentials("UserName", "password".ToSecureString()); + var creds = new UsernameAndPasswordCredentials("UserName", "password".ToSecureString()); var input = new BoundSonarQubeProject(new Uri("http://server"), "ProjectKey", "projectName", creds, new SonarQubeOrganization("org_key", "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 UsernameAndPasswordCredentials; + 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,11 +85,10 @@ 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(); } - + [TestMethod] public void BoundServerProject_CreateConnectionInformation_ArgCheck() { @@ -105,17 +106,15 @@ 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"); } - [TestMethod] public void BoundServerProject_CreateConnectionInformation_BasicAuthCredentials() { // Arrange - var creds = new BasicAuthCredentials("UserName", "password".ToSecureString()); + var creds = new UsernameAndPasswordCredentials("UserName", "password".ToSecureString()); var input = new BoundServerProject("solution", "ProjectKey", new ServerConnection.SonarCloud("org_key", credentials: creds)); // Act @@ -123,8 +122,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 UsernameAndPasswordCredentials; + 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 +140,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.UnitTests/Persistence/BoundSonarQubeProjectTests.cs b/src/ConnectedMode.UnitTests/Persistence/BoundSonarQubeProjectTests.cs index 1318f7cdd8..17170837d8 100644 --- a/src/ConnectedMode.UnitTests/Persistence/BoundSonarQubeProjectTests.cs +++ b/src/ConnectedMode.UnitTests/Persistence/BoundSonarQubeProjectTests.cs @@ -36,7 +36,7 @@ public void BoundProject_Serialization() // Arrange var serverUri = new Uri("https://finding-nemo.org"); var projectKey = "MyProject Key"; - var testSubject = new BoundSonarQubeProject(serverUri, projectKey, "projectName", new BasicAuthCredentials("used", "pwd".ToSecureString())); + var testSubject = new BoundSonarQubeProject(serverUri, projectKey, "projectName", new UsernameAndPasswordCredentials("used", "pwd".ToSecureString())); // Act (serialize + de-serialize) string data = JsonHelper.Serialize(testSubject); @@ -48,14 +48,14 @@ public void BoundProject_Serialization() deserialized.ServerUri.Should().Be(testSubject.ServerUri); deserialized.Credentials.Should().BeNull(); } - + [TestMethod] public void BoundProject_BindingJsonModel_Serialization() { // Arrange var serverUri = new Uri("https://finding-nemo.org"); var projectKey = "MyProject Key"; - var testSubject = new BoundSonarQubeProject(serverUri, projectKey, "projectName", new BasicAuthCredentials("used", "pwd".ToSecureString())); + var testSubject = new BoundSonarQubeProject(serverUri, projectKey, "projectName", new UsernameAndPasswordCredentials("used", "pwd".ToSecureString())); // Act (serialize + de-serialize) string data = JsonHelper.Serialize(testSubject); diff --git a/src/ConnectedMode.UnitTests/Persistence/CredentialsExtensionMethodsTests.cs b/src/ConnectedMode.UnitTests/Persistence/CredentialsExtensionMethodsTests.cs new file mode 100644 index 0000000000..7e306e0aca --- /dev/null +++ b/src/ConnectedMode.UnitTests/Persistence/CredentialsExtensionMethodsTests.cs @@ -0,0 +1,104 @@ +/* + * 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 Microsoft.Alm.Authentication; +using SonarLint.VisualStudio.ConnectedMode.Persistence; +using SonarQube.Client.Helpers; +using SonarQube.Client.Models; + +namespace SonarLint.VisualStudio.ConnectedMode.UnitTests.Persistence; + +[TestClass] +public class CredentialsExtensionMethodsTests +{ + [TestMethod] + public void ToCredential_NullCredentials_Throws() => Assert.ThrowsException(() => ((IConnectionCredentials)null).ToCredential()); + + [TestMethod] + public void ToCredential_UsernameAndPasswordCredentials_ReturnsExpected() + { + var basicCredentials = new UsernameAndPasswordCredentials("user", "pwd".ToSecureString()); + + var result = basicCredentials.ToCredential(); + + result.Username.Should().Be(basicCredentials.UserName); + result.Password.Should().Be(basicCredentials.Password.ToUnsecureString()); + } + + [TestMethod] + public void ToCredential_TokenAuthCredentials_ReturnsExpected() + { + var tokenAuthCredentials = new TokenAuthCredentials("token".ToSecureString()); + + var result = tokenAuthCredentials.ToCredential(); + + result.Username.Should().Be(tokenAuthCredentials.Token.ToUnsecureString()); + result.Password.Should().Be(string.Empty); + } + + [TestMethod] + public void ToICredentials_UsernameIsEmpty_ReturnsUsernameAndPasswordCredentialsWithPasswordAsToken() + { + var credential = new Credential(string.Empty, "token"); + + var result = credential.ToICredentials(); + + var basicCredentials = result as UsernameAndPasswordCredentials; + basicCredentials.Should().NotBeNull(); + basicCredentials.UserName.Should().Be(credential.Username); + basicCredentials.Password.ToUnsecureString().Should().Be(credential.Password); + } + + /// + /// For backward compatibility + /// + [TestMethod] + public void ToICredentials_PasswordIsEmpty_ReturnsTokenAuthCredentialsWithUsernameAsToken() + { + var credential = new Credential("token", string.Empty); + + var result = credential.ToICredentials(); + + var tokenAuth = result as TokenAuthCredentials; + tokenAuth.Should().NotBeNull(); + tokenAuth.Token.ToUnsecureString().Should().Be(credential.Username); + } + + [TestMethod] + public void ToICredentials_PasswordAndUsernameFilled_ReturnsUsernameAndPasswordCredentials() + { + var credential = new Credential("username", "pwd"); + + var result = credential.ToICredentials(); + + var basicCredentials = result as UsernameAndPasswordCredentials; + basicCredentials.Should().NotBeNull(); + basicCredentials.UserName.Should().Be(credential.Username); + basicCredentials.Password.ToUnsecureString().Should().Be(credential.Password); + } + + [TestMethod] + public void ToICredentials_Null_ReturnsNull() + { + var result = ((Credential)null).ToICredentials(); + + result.Should().BeNull(); + } +} diff --git a/src/ConnectedMode.UnitTests/Persistence/ServerConnectionsRepositoryTests.cs b/src/ConnectedMode.UnitTests/Persistence/ServerConnectionsRepositoryTests.cs index 03e2e2a209..e156c5ae7e 100644 --- a/src/ConnectedMode.UnitTests/Persistence/ServerConnectionsRepositoryTests.cs +++ b/src/ConnectedMode.UnitTests/Persistence/ServerConnectionsRepositoryTests.cs @@ -27,6 +27,7 @@ using SonarLint.VisualStudio.Core.Binding; using SonarLint.VisualStudio.Core.Persistence; using SonarLint.VisualStudio.TestInfrastructure; +using SonarQube.Client.Models; using static SonarLint.VisualStudio.Core.Binding.ServerConnection; namespace SonarLint.VisualStudio.ConnectedMode.UnitTests.Persistence; @@ -40,13 +41,13 @@ public class ServerConnectionsRepositoryTests private IEnvironmentVariableProvider environmentVariableProvider; private IServerConnectionModelMapper serverConnectionModelMapper; private ISolutionBindingCredentialsLoader credentialsLoader; - private readonly SonarCloud sonarCloudServerConnection = new("myOrganization", new ServerConnectionSettings(true), Substitute.For()); - private readonly ServerConnection.SonarQube sonarQubeServerConnection = new(new Uri("http://localhost"), new ServerConnectionSettings(true), Substitute.For()); + private readonly SonarCloud sonarCloudServerConnection = new("myOrganization", new ServerConnectionSettings(true), Substitute.For()); + private readonly ServerConnection.SonarQube sonarQubeServerConnection = new(new Uri("http://localhost"), new ServerConnectionSettings(true), Substitute.For()); private IFileSystem fileSystem; [TestInitialize] public void TestInitialize() - { + { jsonFileHandler = Substitute.For(); serverConnectionModelMapper = Substitute.For(); credentialsLoader = Substitute.For(); @@ -128,7 +129,7 @@ public void TryGet_FileExistsAndConnectionIsSonarCloud_ReturnsSonarCloudConnecti public void TryGet_FileExistsAndConnectionIsSonarCloud_FillsCredentials() { var expectedConnection = MockFileWithOneSonarCloudConnection(); - var credentials = Substitute.For(); + var credentials = Substitute.For(); credentialsLoader.Load(expectedConnection.CredentialsUri).Returns(credentials); var succeeded = testSubject.TryGet(expectedConnection.Id, out ServerConnection serverConnection); @@ -157,7 +158,7 @@ public void TryGet_FileExistsAndConnectionIsSonarQube_ReturnsSonarQubeConnection public void TryGet_FileExistsAndConnectionIsSonarQube_FillsCredentials() { var expectedConnection = MockFileWithOneSonarQubeConnection(); - var credentials = Substitute.For(); + var credentials = Substitute.For(); credentialsLoader.Load(expectedConnection.CredentialsUri).Returns(credentials); var succeeded = testSubject.TryGet(expectedConnection.Id, out ServerConnection serverConnection); @@ -174,7 +175,7 @@ public void TryGetAll_FileDoesNotExist_ReturnsEmptyList() MockReadingFile(new ServerConnectionsListJsonModel()); jsonFileHandler.When(x => x.ReadFile(Arg.Any())).Do(x => throw new FileNotFoundException()); - testSubject.TryGetAll(out var connections); + testSubject.TryGetAll(out var connections); connections.Should().BeEmpty(); } @@ -196,7 +197,7 @@ public void TryGetAll_FileExistsAndIsEmpty_ReturnsEmptyList() { MockReadingFile(new ServerConnectionsListJsonModel()); - testSubject.TryGetAll(out var connections); + testSubject.TryGetAll(out var connections); connections.Should().BeEmpty(); } @@ -289,7 +290,7 @@ public void TryAdd_ConnectionIsAddedAndCredentialsAreNull_ReturnsFalse() var succeeded = testSubject.TryAdd(sonarCloudServerConnection); succeeded.Should().BeFalse(); - credentialsLoader.DidNotReceive().Save(Arg.Any(), Arg.Any()); + credentialsLoader.DidNotReceive().Save(Arg.Any(), Arg.Any()); } [TestMethod] @@ -300,7 +301,7 @@ public void TryAdd_ConnectionIsNotAdded_DoesNotSaveCredentials() var succeeded = testSubject.TryAdd(sonarCloud); succeeded.Should().BeFalse(); - credentialsLoader.DidNotReceive().Save(Arg.Any(), Arg.Any()); + credentialsLoader.DidNotReceive().Save(Arg.Any(), Arg.Any()); } [TestMethod] @@ -529,7 +530,8 @@ public void TryUpdateSettingsById_FileExistsAndConnectionExists_UpdatesSettings( Received.InOrder(() => { jsonFileHandler.ReadFile(Arg.Any()); - serverConnectionModelMapper.GetServerConnectionsListJsonModel(Arg.Is>(x => x.Count() == 1 && x.Single().Settings.IsSmartNotificationsEnabled == newSmartNotifications)); + serverConnectionModelMapper.GetServerConnectionsListJsonModel(Arg.Is>(x => + x.Count() == 1 && x.Single().Settings.IsSmartNotificationsEnabled == newSmartNotifications)); jsonFileHandler.TryWriteToFile(Arg.Any(), Arg.Any()); }); } @@ -589,17 +591,17 @@ public void TryUpdateCredentialsById_ConnectionDoesNotExist_DoesNotUpdateCredent { MockReadingFile(new ServerConnectionsListJsonModel()); - var succeeded = testSubject.TryUpdateCredentialsById("myConn", Substitute.For()); + var succeeded = testSubject.TryUpdateCredentialsById("myConn", Substitute.For()); succeeded.Should().BeFalse(); - credentialsLoader.DidNotReceive().Save(Arg.Any(), Arg.Any()); + credentialsLoader.DidNotReceive().Save(Arg.Any(), Arg.Any()); } [TestMethod] public void TryUpdateCredentialsById_SonarCloudConnectionExists_UpdatesCredentials() { var sonarCloud = MockFileWithOneSonarCloudConnection(); - var newCredentials = Substitute.For(); + var newCredentials = Substitute.For(); var succeeded = testSubject.TryUpdateCredentialsById(sonarCloud.Id, newCredentials); @@ -611,7 +613,7 @@ public void TryUpdateCredentialsById_SonarCloudConnectionExists_UpdatesCredentia public void TryUpdateCredentialsById_SonarQubeConnectionExists_UpdatesCredentials() { var sonarQube = MockFileWithOneSonarQubeConnection(); - var newCredentials = Substitute.For(); + var newCredentials = Substitute.For(); var succeeded = testSubject.TryUpdateCredentialsById(sonarQube.Id, newCredentials); @@ -626,7 +628,7 @@ public void TryUpdateCredentialsById_DoesNotUpdateCredentials_DoesNotInvokeConne var eventHandler = Substitute.For>(); testSubject.CredentialsChanged += eventHandler; - testSubject.TryUpdateCredentialsById("non-existingConn", Substitute.For()); + testSubject.TryUpdateCredentialsById("non-existingConn", Substitute.For()); eventHandler.DidNotReceive().Invoke(testSubject, Arg.Any()); } @@ -638,7 +640,7 @@ public void TryUpdateCredentialsById_UpdatesCredentials_InvokesConnectionChanged var eventHandler = Substitute.For>(); testSubject.CredentialsChanged += eventHandler; - testSubject.TryUpdateCredentialsById(sonarQube.Id, Substitute.For()); + testSubject.TryUpdateCredentialsById(sonarQube.Id, Substitute.For()); eventHandler.Received(1).Invoke(testSubject, Arg.Is(args => args.ServerConnection == sonarQube)); } @@ -660,9 +662,9 @@ public void TryUpdateCredentialsById_SavingCredentialsThrows_ReturnsFalseAndLogs { var exceptionMsg = "failed"; var connection = MockFileWithOneSonarCloudConnection(); - credentialsLoader.When(x => x.Save(Arg.Any(), Arg.Any())).Do(x => throw new Exception(exceptionMsg)); + credentialsLoader.When(x => x.Save(Arg.Any(), Arg.Any())).Do(x => throw new Exception(exceptionMsg)); - var succeeded = testSubject.TryUpdateCredentialsById(connection.Id, Substitute.For()); + var succeeded = testSubject.TryUpdateCredentialsById(connection.Id, Substitute.For()); succeeded.Should().BeFalse(); logger.Received(1).WriteLine($"Failed updating credentials: {exceptionMsg}"); @@ -671,17 +673,17 @@ public void TryUpdateCredentialsById_SavingCredentialsThrows_ReturnsFalseAndLogs private SonarCloud MockFileWithOneSonarCloudConnection(bool isSmartNotificationsEnabled = true) { var sonarCloudModel = GetSonarCloudJsonModel(isSmartNotificationsEnabled); - var sonarCloud = new SonarCloud(sonarCloudModel.OrganizationKey, sonarCloudModel.Settings, Substitute.For()); + var sonarCloud = new SonarCloud(sonarCloudModel.OrganizationKey, sonarCloudModel.Settings, Substitute.For()); MockReadingFile(new ServerConnectionsListJsonModel { ServerConnections = [sonarCloudModel] }); serverConnectionModelMapper.GetServerConnection(sonarCloudModel).Returns(sonarCloud); - + return sonarCloud; } private ServerConnection.SonarQube MockFileWithOneSonarQubeConnection(bool isSmartNotificationsEnabled = true) { var sonarQubeModel = GetSonarQubeJsonModel(new Uri("http://localhost"), isSmartNotificationsEnabled); - var sonarQube = new ServerConnection.SonarQube(new Uri(sonarQubeModel.ServerUri), sonarQubeModel.Settings, Substitute.For()); + var sonarQube = new ServerConnection.SonarQube(new Uri(sonarQubeModel.ServerUri), sonarQubeModel.Settings, Substitute.For()); MockReadingFile(new ServerConnectionsListJsonModel { ServerConnections = [sonarQubeModel] }); serverConnectionModelMapper.GetServerConnection(sonarQubeModel).Returns(sonarQube); @@ -695,22 +697,11 @@ private void MockReadingFile(ServerConnectionsListJsonModel modelToReturn) private static ServerConnectionJsonModel GetSonarCloudJsonModel(bool isSmartNotificationsEnabled = false) { - return new ServerConnectionJsonModel - { - Id = "https://sonarcloud.io/organizations/myOrg", - OrganizationKey = "myOrg", - Settings = new ServerConnectionSettings(isSmartNotificationsEnabled) - }; + return new ServerConnectionJsonModel { Id = "https://sonarcloud.io/organizations/myOrg", OrganizationKey = "myOrg", Settings = new ServerConnectionSettings(isSmartNotificationsEnabled) }; } private static ServerConnectionJsonModel GetSonarQubeJsonModel(Uri id, bool isSmartNotificationsEnabled = false) { - return new ServerConnectionJsonModel - { - Id = id.ToString(), - ServerUri = id.ToString(), - Settings = new ServerConnectionSettings(isSmartNotificationsEnabled) - }; + return new ServerConnectionJsonModel { Id = id.ToString(), ServerUri = id.ToString(), Settings = new ServerConnectionSettings(isSmartNotificationsEnabled) }; } - } diff --git a/src/ConnectedMode.UnitTests/Persistence/SolutionBindingCredentialsLoaderTests.cs b/src/ConnectedMode.UnitTests/Persistence/SolutionBindingCredentialsLoaderTests.cs index d92a4bd945..c615f70f8e 100644 --- a/src/ConnectedMode.UnitTests/Persistence/SolutionBindingCredentialsLoaderTests.cs +++ b/src/ConnectedMode.UnitTests/Persistence/SolutionBindingCredentialsLoaderTests.cs @@ -23,6 +23,7 @@ using SonarLint.VisualStudio.ConnectedMode.Persistence; using SonarLint.VisualStudio.Core.Binding; using SonarQube.Client.Helpers; +using SonarQube.Client.Models; namespace SonarLint.VisualStudio.ConnectedMode.UnitTests.Persistence { @@ -53,15 +54,17 @@ public void Ctor_NullStore_Exception() public void Load_ServerUriIsNull_Null() { var actual = testSubject.Load(null); + actual.Should().Be(null); } [TestMethod] public void Load_NoCredentials_Null() { - store.ReadCredentials(mockUri).Returns(null as Credential); + MockReadCredentials(mockUri, null); var actual = testSubject.Load(mockUri); + actual.Should().Be(null); } @@ -69,18 +72,41 @@ public void Load_NoCredentials_Null() public void Load_CredentialsExist_CredentialsWithSecuredString() { var credentials = new Credential("user", "password"); - store - .ReadCredentials(Arg.Is(t => t.ActualUri == mockUri)) - .Returns(credentials); + MockReadCredentials(mockUri, credentials); + + var actual = testSubject.Load(mockUri); + actual.Should().BeEquivalentTo(new UsernameAndPasswordCredentials("user", "password".ToSecureString())); + } + + [TestMethod] + public void Load_CredentialsExist_UsernameIsEmpty_BasicAuthCredentialsWithSecuredString() + { + var credentials = new Credential(string.Empty, "token"); + MockReadCredentials(mockUri, credentials); + + var actual = testSubject.Load(mockUri); + + actual.Should().BeEquivalentTo(new UsernameAndPasswordCredentials(string.Empty, "token".ToSecureString())); + } + + /// + /// For backward compatibility + /// + [TestMethod] + public void Load_CredentialsExist_PasswordIsEmpty_TokenCredentialsWithSecuredString() + { + var credentials = new Credential("token", string.Empty); + MockReadCredentials(mockUri, credentials); var actual = testSubject.Load(mockUri); - actual.Should().BeEquivalentTo(new BasicAuthCredentials("user", "password".ToSecureString())); + + actual.Should().BeEquivalentTo(new TokenAuthCredentials("token".ToSecureString())); } [TestMethod] public void Save_ServerUriIsNull_CredentialsNotSaved() { - var credentials = new BasicAuthCredentials("user", "password".ToSecureString()); + var credentials = new UsernameAndPasswordCredentials("user", "password".ToSecureString()); testSubject.Save(credentials, null); @@ -98,8 +124,14 @@ public void Save_CredentialsAreNull_CredentialsNotSaved() [TestMethod] public void Save_CredentialsAreNotBasicAuth_CredentialsNotSaved() { - var mockCredentials = new Mock(); - testSubject.Save(mockCredentials.Object, mockUri); + try + { + testSubject.Save(new Mock().Object, mockUri); + } + catch (Exception) + { + // ignored + } store.DidNotReceive().WriteCredentials(Arg.Any(), Arg.Any()); } @@ -107,13 +139,26 @@ public void Save_CredentialsAreNotBasicAuth_CredentialsNotSaved() [TestMethod] public void Save_CredentialsAreBasicAuth_CredentialsSavedWithUnsecuredString() { - var credentials = new BasicAuthCredentials("user", "password".ToSecureString()); + var credentials = new UsernameAndPasswordCredentials("user", "password".ToSecureString()); testSubject.Save(credentials, mockUri); store.Received(1) .WriteCredentials( - Arg.Is(t => t.ActualUri == mockUri), - Arg.Is(c=> c.Username == "user" && c.Password == "password")); + Arg.Is(t => t.ActualUri == mockUri), + Arg.Is(c => c.Username == "user" && c.Password == "password")); + } + + [TestMethod] + public void Save_CredentialsAreTokenAuth_CredentialsSavedWithUnsecuredString() + { + var credentials = new TokenAuthCredentials("token".ToSecureString()); + + testSubject.Save(credentials, mockUri); + + store.Received(1) + .WriteCredentials( + Arg.Is(t => t.ActualUri == mockUri), + Arg.Is(c => c.Username == "token" && c.Password == string.Empty)); } [TestMethod] @@ -131,5 +176,10 @@ public void DeleteCredentials_UriProvided_CallsStoreDeleteCredentials() store.Received(1).DeleteCredentials(Arg.Any()); } + + private void MockReadCredentials(Uri uri, Credential credentials) => + store + .ReadCredentials(Arg.Is(t => t.ActualUri == uri)) + .Returns(credentials); } } diff --git a/src/ConnectedMode.UnitTests/Persistence/SolutionBindingRepositoryTests.cs b/src/ConnectedMode.UnitTests/Persistence/SolutionBindingRepositoryTests.cs index 7ad191e611..85d2ee84ab 100644 --- a/src/ConnectedMode.UnitTests/Persistence/SolutionBindingRepositoryTests.cs +++ b/src/ConnectedMode.UnitTests/Persistence/SolutionBindingRepositoryTests.cs @@ -40,7 +40,7 @@ public class SolutionBindingRepositoryTests private ISolutionBindingCredentialsLoader credentialsLoader; private TestLogger logger; - private BasicAuthCredentials mockCredentials; + private UsernameAndPasswordCredentials mockCredentials; private ServerConnection serverConnection; private IServerConnectionsRepository serverConnectionsRepository; private ISolutionBindingFileLoader solutionBindingFileLoader; @@ -59,7 +59,7 @@ public void TestInitialize() testSubject = new SolutionBindingRepository(unintrusiveBindingPathProvider, bindingJsonModelConverter, serverConnectionsRepository, solutionBindingFileLoader, credentialsLoader, logger); - mockCredentials = new BasicAuthCredentials("user", "pwd".ToSecureString()); + mockCredentials = new UsernameAndPasswordCredentials("user", "pwd".ToSecureString()); serverConnection = new ServerConnection.SonarCloud("org"); boundServerProject = new BoundServerProject("solution.123", "project_123", serverConnection); @@ -310,7 +310,7 @@ public void DeleteBinding_DirectoryNotDeleted_EventNotTriggered() { var eventHandler = Substitute.For>(); testSubject.BindingDeleted += eventHandler; - MockDeletingBindingDirectory(LocalBindingKey, deleted:false); + MockDeletingBindingDirectory(LocalBindingKey, deleted: false); testSubject.DeleteBinding(LocalBindingKey); diff --git a/src/ConnectedMode.UnitTests/Persistence/TokenAuthCredentialsTests.cs b/src/ConnectedMode.UnitTests/Persistence/TokenAuthCredentialsTests.cs new file mode 100644 index 0000000000..1fe9f83bb3 --- /dev/null +++ b/src/ConnectedMode.UnitTests/Persistence/TokenAuthCredentialsTests.cs @@ -0,0 +1,61 @@ +/* + * 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 TokenAuthCredentialsTests +{ + private const string Token = "token"; + + [TestMethod] + public void Ctor_WhenTokenIsNull_ThrowsArgumentNullException() + { + Action act = () => new TokenAuthCredentials(null); + + act.Should().Throw(); + } + + [TestMethod] + public void Dispose_DisposesPassword() + { + var testSubject = new TokenAuthCredentials(Token.ToSecureString()); + + testSubject.Dispose(); + + Exceptions.Expect(() => testSubject.Token.ToUnsecureString()); + } + + [TestMethod] + public void Clone_ClonesPassword() + { + var testSubject = new TokenAuthCredentials(Token.ToSecureString()); + + var clone = (TokenAuthCredentials)testSubject.Clone(); + + clone.Should().NotBeSameAs(testSubject); + clone.Token.Should().NotBeSameAs(testSubject.Token); + clone.Token.ToUnsecureString().Should().Be(testSubject.Token.ToUnsecureString()); + } +} diff --git a/src/ConnectedMode.UnitTests/Persistence/UsernameAndPasswordCredentialsTests.cs b/src/ConnectedMode.UnitTests/Persistence/UsernameAndPasswordCredentialsTests.cs new file mode 100644 index 0000000000..e573bf78d9 --- /dev/null +++ b/src/ConnectedMode.UnitTests/Persistence/UsernameAndPasswordCredentialsTests.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 UsernameAndPasswordCredentialsTests +{ + private const string Username = "username"; + private const string Password = "pwd"; + + [TestMethod] + public void Ctor_WhenUsernameIsNull_ThrowsArgumentNullException() + { + Action act = () => new UsernameAndPasswordCredentials(null, Password.ToSecureString()); + + act.Should().Throw(); + } + + [TestMethod] + public void Ctor_WhenPasswordIsNull_ThrowsArgumentNullException() + { + Action act = () => new UsernameAndPasswordCredentials(Username, null); + + act.Should().Throw(); + } + + [TestMethod] + public void Dispose_DisposesPassword() + { + var testSubject = new UsernameAndPasswordCredentials(Username, Password.ToSecureString()); + + testSubject.Dispose(); + + Exceptions.Expect(() => testSubject.Password.ToUnsecureString()); + } + + [TestMethod] + public void Clone_ClonesPassword() + { + var password = "pwd"; + var testSubject = new UsernameAndPasswordCredentials(Username, password.ToSecureString()); + + var clone = (UsernameAndPasswordCredentials)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/ServerConnectionsRepositoryAdapterTests.cs b/src/ConnectedMode.UnitTests/ServerConnectionsRepositoryAdapterTests.cs index 363a1798af..6c44c87521 100644 --- a/src/ConnectedMode.UnitTests/ServerConnectionsRepositoryAdapterTests.cs +++ b/src/ConnectedMode.UnitTests/ServerConnectionsRepositoryAdapterTests.cs @@ -23,6 +23,7 @@ using SonarLint.VisualStudio.Core.Binding; using SonarLint.VisualStudio.TestInfrastructure; using SonarQube.Client.Helpers; +using SonarQube.Client.Models; using static SonarLint.VisualStudio.Core.Binding.ServerConnection; namespace SonarLint.VisualStudio.ConnectedMode.UnitTests; @@ -41,8 +42,8 @@ public void TestInitialize() } [TestMethod] - public void MefCtor_CheckIsExported() - => MefTestHelpers.CheckTypeCanBeImported(MefTestHelpers.CreateExport()); + public void MefCtor_CheckIsExported() => + MefTestHelpers.CheckTypeCanBeImported(MefTestHelpers.CreateExport()); [TestMethod] public void TryGetServerConnectionById_CallServerConnectionsRepository() @@ -55,10 +56,10 @@ public void TryGetServerConnectionById_CallServerConnectionsRepository() }); testSubject.TryGet(new ConnectionInfo("myOrg", ConnectionServerType.SonarCloud), out var serverConnection); - + serverConnection.Should().Be(expectedServerConnection); } - + [TestMethod] public void TryGetAllConnections_CallServerConnectionsRepository() { @@ -102,7 +103,7 @@ public void TryGetAllConnections_HasOneSonarQubeConnection_ReturnsOneMappedConne public void TryGetAllConnections_ReturnsStatusFromSlCore(bool expectedStatus) { var sonarCloud = CreateSonarCloudServerConnection(); - MockServerConnections([sonarCloud], succeeded:expectedStatus); + MockServerConnections([sonarCloud], succeeded: expectedStatus); var succeeded = testSubject.TryGetAllConnections(out _); @@ -170,7 +171,7 @@ public void TryAddConnection_ReturnsStatusFromSlCore(bool expectedStatus) [TestMethod] [DataRow(true)] - [DataRow(false)] + [DataRow(false)] public void TryAddConnection_AddsSonarCloudConnection_CallsSlCoreWithMappedConnection(bool enableSmartNotifications) { var sonarCloud = CreateSonarCloudConnection(enableSmartNotifications); @@ -209,7 +210,7 @@ public void TryAddConnection_TokenCredentialsModel_MapsCredentials() testSubject.TryAddConnection(sonarQube, new TokenCredentialsModel(token.CreateSecureString())); serverConnectionsRepository.Received(1) - .TryAdd(Arg.Is(sq => IsExpectedCredentials(sq.Credentials, token, string.Empty))); + .TryAdd(Arg.Is(sq => IsExpectedTokenCredentials(sq.Credentials, token))); } [TestMethod] @@ -234,14 +235,14 @@ public void TryAddConnection_NullCredentials_TriesAddingAConnectionWithNoCredent serverConnectionsRepository.Received(1).TryAdd(Arg.Is(sq => sq.Credentials == null)); } - + [TestMethod] [DataRow(true)] [DataRow(false)] public void TryUpdateCredentials_ReturnsStatusFromSlCore(bool slCoreResponse) { var sonarCloud = CreateSonarCloudConnection(); - serverConnectionsRepository.TryUpdateCredentialsById(Arg.Any(), Arg.Any()).Returns(slCoreResponse); + serverConnectionsRepository.TryUpdateCredentialsById(Arg.Any(), Arg.Any()).Returns(slCoreResponse); var succeeded = testSubject.TryUpdateCredentials(sonarCloud, Substitute.For()); @@ -257,44 +258,44 @@ public void TryUpdateCredentials_TokenCredentialsModel_MapsCredentials() testSubject.TryUpdateCredentials(sonarQube, new TokenCredentialsModel(token.CreateSecureString())); serverConnectionsRepository.Received(1) - .TryUpdateCredentialsById(Arg.Any(), Arg.Is(x => IsExpectedCredentials(x, token, string.Empty))); + .TryUpdateCredentialsById(Arg.Any(), Arg.Is(x => IsExpectedTokenCredentials(x, token))); } - + [TestMethod] public void TryUpdateCredentials_UserPasswordModel_MapsCredentials() { var sonarQube = CreateSonarQubeConnection(); const string username = "username"; const string password = "password"; - + testSubject.TryUpdateCredentials(sonarQube, new UsernamePasswordModel(username, password.CreateSecureString())); - + serverConnectionsRepository.Received(1) - .TryUpdateCredentialsById(Arg.Any(), Arg.Is(x => IsExpectedCredentials(x, username, password))); + .TryUpdateCredentialsById(Arg.Any(), Arg.Is(x => IsExpectedCredentials(x, username, password))); } - + [TestMethod] public void TryUpdateCredentials_SonarQube_MapsConnection() { var sonarQube = CreateSonarQubeConnection(); - + testSubject.TryUpdateCredentials(sonarQube, Substitute.For()); - + serverConnectionsRepository.Received(1) - .TryUpdateCredentialsById(Arg.Is(x => x.Equals(sonarQube.Info.Id)), Arg.Any()); + .TryUpdateCredentialsById(Arg.Is(x => x.Equals(sonarQube.Info.Id)), Arg.Any()); } - + [TestMethod] public void TryUpdateCredentials_SonarCloud_MapsConnection() { var sonarCloud = CreateSonarCloudConnection(); - + testSubject.TryUpdateCredentials(sonarCloud, Substitute.For()); - + serverConnectionsRepository.Received(1) - .TryUpdateCredentialsById(Arg.Is(x => x.EndsWith(sonarCloud.Info.Id)), Arg.Any()); + .TryUpdateCredentialsById(Arg.Is(x => x.EndsWith(sonarCloud.Info.Id)), Arg.Any()); } - + [TestMethod] public void TryUpdateCredentials_NullCredentials_TriesUpdatingConnectionWithNoCredentials() { @@ -302,7 +303,7 @@ public void TryUpdateCredentials_NullCredentials_TriesUpdatingConnectionWithNoCr testSubject.TryUpdateCredentials(sonarQube, null); - serverConnectionsRepository.Received(1).TryUpdateCredentialsById(Arg.Any(), Arg.Is(x => x == null)); + serverConnectionsRepository.Received(1).TryUpdateCredentialsById(Arg.Any(), Arg.Is(x => x == null)); } [TestMethod] @@ -337,12 +338,12 @@ public void TryGet_ReturnsStatusFromSlCore(bool expectedStatus) private static SonarCloud CreateSonarCloudServerConnection(bool isSmartNotificationsEnabled = true) { - return new SonarCloud("myOrg", new ServerConnectionSettings(isSmartNotificationsEnabled), Substitute.For()); + return new SonarCloud("myOrg", new ServerConnectionSettings(isSmartNotificationsEnabled), Substitute.For()); } private static ServerConnection.SonarQube CreateSonarQubeServerConnection(bool isSmartNotificationsEnabled = true) { - var sonarQube = new ServerConnection.SonarQube(new Uri("http://localhost"), new ServerConnectionSettings(isSmartNotificationsEnabled), Substitute.For()); + var sonarQube = new ServerConnection.SonarQube(new Uri("http://localhost"), new ServerConnectionSettings(isSmartNotificationsEnabled), Substitute.For()); return sonarQube; } @@ -364,10 +365,16 @@ private static Connection CreateSonarQubeConnection(bool enableSmartNotification { return new Connection(new ConnectionInfo("http://localhost:9000/", ConnectionServerType.SonarQube), enableSmartNotifications); } - - private static bool IsExpectedCredentials(ICredentials credentials, string expectedUsername, string expectedPassword) + + private static bool IsExpectedCredentials(IConnectionCredentials credentials, string expectedUsername, string expectedPassword) + { + return credentials is UsernameAndPasswordCredentials basicAuthCredentials && basicAuthCredentials.UserName == expectedUsername && + basicAuthCredentials.Password?.ToUnsecureString() == expectedPassword; + } + + private static bool IsExpectedTokenCredentials(IConnectionCredentials credentials, string expectedToken) { - return credentials is BasicAuthCredentials basicAuthCredentials && basicAuthCredentials.UserName == expectedUsername && basicAuthCredentials.Password?.ToUnsecureString() == expectedPassword; + return credentials is TokenAuthCredentials tokenAuthCredentials && tokenAuthCredentials.Token?.ToUnsecureString() == expectedToken; } private void MockTryGet(string connectionId, bool expectedResponse, ServerConnection expectedServerConnection) diff --git a/src/ConnectedMode.UnitTests/SlCoreConnectionAdapterTests.cs b/src/ConnectedMode.UnitTests/SlCoreConnectionAdapterTests.cs index d6c2e72896..4b6210376b 100644 --- a/src/ConnectedMode.UnitTests/SlCoreConnectionAdapterTests.cs +++ b/src/ConnectedMode.UnitTests/SlCoreConnectionAdapterTests.cs @@ -18,7 +18,6 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -using System.Security; using SonarLint.VisualStudio.ConnectedMode.Persistence; using SonarLint.VisualStudio.ConnectedMode.UI.Credentials; using SonarLint.VisualStudio.ConnectedMode.UI.OrganizationSelection; @@ -32,16 +31,17 @@ using SonarLint.VisualStudio.SLCore.Service.Connection; using SonarLint.VisualStudio.SLCore.Service.Connection.Models; using SonarLint.VisualStudio.TestInfrastructure; +using SonarQube.Client.Helpers; namespace SonarLint.VisualStudio.ConnectedMode.UnitTests; [TestClass] public class SlCoreConnectionAdapterTests { - private static readonly BasicAuthCredentials ValidToken = new ("I_AM_JUST_A_TOKEN", new SecureString()); - private readonly ServerConnection.SonarQube sonarQubeConnection = new(new Uri("http://localhost:9000/"), new ServerConnectionSettings(true), ValidToken); - private readonly ServerConnection.SonarCloud sonarCloudConnection = new("myOrg", new ServerConnectionSettings(true), ValidToken); - + private static readonly TokenAuthCredentials ValidTokenAuth = new("I_AM_JUST_A_TOKEN".ToSecureString()); + private readonly ServerConnection.SonarQube sonarQubeConnection = new(new Uri("http://localhost:9000/"), new ServerConnectionSettings(true), ValidTokenAuth); + private readonly ServerConnection.SonarCloud sonarCloudConnection = new("myOrg", new ServerConnectionSettings(true), ValidTokenAuth); + private SlCoreConnectionAdapter testSubject; private ISLCoreServiceProvider slCoreServiceProvider; private IThreadHandling threadHandling; @@ -200,7 +200,7 @@ public async Task GetOrganizationsAsync_TokenIsProvided_CallsSlCoreListUserOrgan await testSubject.GetOrganizationsAsync(new TokenCredentialsModel(token.CreateSecureString())); - await connectionConfigurationSlCoreService.Received(1).ListUserOrganizationsAsync(Arg.Is(x=> IsExpectedCredentials(x.credentials, token))); + await connectionConfigurationSlCoreService.Received(1).ListUserOrganizationsAsync(Arg.Is(x => IsExpectedCredentials(x.credentials, token))); } [TestMethod] @@ -257,7 +257,7 @@ public async Task GetAllProjectsAsync_SwitchesToBackgroundThread() { var threadHandlingMock = Substitute.For(); var slCoreConnectionAdapter = new SlCoreConnectionAdapter(slCoreServiceProvider, threadHandlingMock, logger); - + await slCoreConnectionAdapter.GetAllProjectsAsync(sonarQubeConnection); await threadHandlingMock.Received(1).RunOnBackgroundThread(Arg.Any>>>>()); @@ -280,7 +280,7 @@ public async Task GetAllProjectsAsync_ConnectionToSonarQubeWithToken_CallsGetAll await testSubject.GetAllProjectsAsync(sonarQubeConnection); await connectionConfigurationSlCoreService.Received(1) - .GetAllProjectsAsync(Arg.Is(x => IsExpectedSonarQubeConnectionParams(x.transientConnection, ValidToken.UserName))); + .GetAllProjectsAsync(Arg.Is(x => IsExpectedSonarQubeConnectionParams(x.transientConnection, ValidTokenAuth.Token.ToUnsecureString()))); } [TestMethod] @@ -288,8 +288,8 @@ public async Task GetAllProjectsAsync_ConnectionToSonarQubeWithCredentials_Calls { const string username = "username"; const string password = "password"; - sonarQubeConnection.Credentials = new BasicAuthCredentials(username, password.CreateSecureString()); - + sonarQubeConnection.Credentials = new UsernameAndPasswordCredentials(username, password.CreateSecureString()); + await testSubject.GetAllProjectsAsync(sonarQubeConnection); await connectionConfigurationSlCoreService.Received(1) @@ -302,7 +302,7 @@ public async Task GetAllProjectsAsync_ConnectionToSonarCloudWithToken_CallsGetAl await testSubject.GetAllProjectsAsync(sonarCloudConnection); await connectionConfigurationSlCoreService.Received(1) - .GetAllProjectsAsync(Arg.Is(x => IsExpectedSonarCloudConnectionParams(x.transientConnection, ValidToken.UserName))); + .GetAllProjectsAsync(Arg.Is(x => IsExpectedSonarCloudConnectionParams(x.transientConnection, ValidTokenAuth.Token.ToUnsecureString()))); } [TestMethod] @@ -310,7 +310,7 @@ public async Task GetAllProjectsAsync_ConnectionToSonarCloudWithCredentials_Call { const string username = "username"; const string password = "password"; - sonarCloudConnection.Credentials = new BasicAuthCredentials(username, password.CreateSecureString()); + sonarCloudConnection.Credentials = new UsernameAndPasswordCredentials(username, password.CreateSecureString()); await testSubject.GetAllProjectsAsync(sonarCloudConnection); @@ -333,7 +333,7 @@ public async Task GetAllProjectsAsync_ReturnsResponseFromSlCore() new ServerProject("projKey2", "projName2") ]); } - + [TestMethod] public async Task GetServerProjectByKeyAsync_SwitchesToBackgroundThread() { @@ -344,7 +344,7 @@ public async Task GetServerProjectByKeyAsync_SwitchesToBackgroundThread() await threadHandlingMock.Received(1).RunOnBackgroundThread(Arg.Any>>>()); } - + [TestMethod] public async Task GetServerProjectByKeyAsync_GettingConnectionConfigurationSLCoreServiceFails_ReturnsFailedResponseAndShouldLog() { @@ -356,7 +356,7 @@ public async Task GetServerProjectByKeyAsync_GettingConnectionConfigurationSLCor response.Success.Should().BeFalse(); response.ResponseData.Should().BeNull(); } - + [TestMethod] public async Task GetServerProjectByKeyAsync_SlCoreThrowsException_ReturnsFailedResponseAndShouldLog() { @@ -370,27 +370,24 @@ public async Task GetServerProjectByKeyAsync_SlCoreThrowsException_ReturnsFailed response.Success.Should().BeFalse(); response.ResponseData.Should().BeNull(); } - + [TestMethod] public async Task GetServerProjectByKeyAsync_ProjectNotFound_ReturnsFailedResponse() { - var slCoreResponse = new Dictionary { {"project-key", null} }; + var slCoreResponse = new Dictionary { { "project-key", null } }; connectionConfigurationSlCoreService.GetProjectNamesByKeyAsync(Arg.Any()) .Returns(new GetProjectNamesByKeyResponse(slCoreResponse)); - + var response = await testSubject.GetServerProjectByKeyAsync(sonarCloudConnection, "project-key"); response.Success.Should().BeFalse(); response.ResponseData.Should().BeNull(); } - + [TestMethod] public async Task GetServerProjectByKeyAsync_ProjectFound_ReturnsSuccessResponseAndMappedOrganizations() { - var slCoreResponse = new Dictionary - { - {"project-key", "project-name"} - }; + var slCoreResponse = new Dictionary { { "project-key", "project-name" } }; connectionConfigurationSlCoreService.GetProjectNamesByKeyAsync(Arg.Any()) .Returns(new GetProjectNamesByKeyResponse(slCoreResponse)); var response = await testSubject.GetServerProjectByKeyAsync(sonarQubeConnection, "project-key"); @@ -399,7 +396,6 @@ public async Task GetServerProjectByKeyAsync_ProjectFound_ReturnsSuccessResponse response.ResponseData.Should().BeEquivalentTo(new ServerProject("project-key", "project-name")); } - [TestMethod] public async Task GetAllProjectsAsync_SlCoreValidationThrowsException_ReturnsUnsuccessfulResponse() { @@ -439,7 +435,7 @@ public async Task FuzzySearchProjectsAsync_GettingConnectionConfigurationSLCoreS public async Task FuzzySearchProjectsAsync_CallsSlCoreWithCorrectParams() { var searchTerm = "proj1"; - + await testSubject.FuzzySearchProjectsAsync(sonarCloudConnection, searchTerm); await connectionConfigurationSlCoreService.Received(1).FuzzySearchProjectsAsync( @@ -459,7 +455,7 @@ public async Task FuzzySearchProjectsAsync_ReturnsResponseFromSlCore() response.ResponseData.Count.Should().Be(expectedServerProjects.Count); response.ResponseData.Should().BeEquivalentTo([ new ServerProject("projKey1", "projName1"), - new ServerProject("projKey2", "projName2") + new ServerProject("projKey2", "projName2") ]); } diff --git a/src/ConnectedMode.UnitTests/UI/ManageBinding/ManageBindingViewModelTests.cs b/src/ConnectedMode.UnitTests/UI/ManageBinding/ManageBindingViewModelTests.cs index a3ece5229c..91e676ba95 100644 --- a/src/ConnectedMode.UnitTests/UI/ManageBinding/ManageBindingViewModelTests.cs +++ b/src/ConnectedMode.UnitTests/UI/ManageBinding/ManageBindingViewModelTests.cs @@ -44,7 +44,7 @@ public class ManageBindingViewModelTests private readonly ServerProject serverProject = new("a-project", "A Project"); private readonly ConnectionInfo sonarQubeConnectionInfo = new("http://localhost:9000", ConnectionServerType.SonarQube); private readonly ConnectionInfo sonarCloudConnectionInfo = new("organization", ConnectionServerType.SonarCloud); - private readonly BasicAuthCredentials validCredentials = new("TOKEN", new SecureString()); + private readonly UsernameAndPasswordCredentials validCredentials = new("TOKEN", new SecureString()); private readonly SharedBindingConfigModel sonarQubeSharedBindingConfigModel = new() { Uri = new Uri("http://localhost:9000"), ProjectKey = "myProj" }; private readonly SharedBindingConfigModel sonarCloudSharedBindingConfigModel = new() { Organization = "myOrg", ProjectKey = "myProj" }; diff --git a/src/ConnectedMode.UnitTests/UI/ProjectSelection/ProjectSelectionViewModelTests.cs b/src/ConnectedMode.UnitTests/UI/ProjectSelection/ProjectSelectionViewModelTests.cs index 304743c649..3512a8cf20 100644 --- a/src/ConnectedMode.UnitTests/UI/ProjectSelection/ProjectSelectionViewModelTests.cs +++ b/src/ConnectedMode.UnitTests/UI/ProjectSelection/ProjectSelectionViewModelTests.cs @@ -24,6 +24,7 @@ using SonarLint.VisualStudio.ConnectedMode.UI.Resources; using SonarLint.VisualStudio.Core; using SonarLint.VisualStudio.Core.Binding; +using SonarQube.Client.Models; namespace SonarLint.VisualStudio.ConnectedMode.UnitTests.UI.ProjectSelection; @@ -37,7 +38,7 @@ public class ProjectSelectionViewModelTests ]; private static readonly ConnectionInfo AConnectionInfo = new("http://localhost:9000", ConnectionServerType.SonarQube); - + private ProjectSelectionViewModel testSubject; private ISlCoreConnectionAdapter slCoreConnectionAdapter; private IProgressReporterViewModel progressReporterViewModel; @@ -65,25 +66,22 @@ public void IsProjectSelected_NoProjectSelected_ReturnsFalse() { testSubject.IsProjectSelected.Should().BeFalse(); } - + [TestMethod] public void IsProjectSelected_ProjectSelected_ReturnsTrue() { testSubject.SelectedProject = new ServerProject("a-project", "A Project"); - + testSubject.IsProjectSelected.Should().BeTrue(); } - + [TestMethod] public void InitProjects_ResetsTheProjectResults() { MockInitializedProjects(AnInitialListOfProjects); testSubject.ProjectResults.Should().BeEquivalentTo(AnInitialListOfProjects); - - var updatedListOfProjects = new List - { - new("new-project", "New Project") - }; + + var updatedListOfProjects = new List { new("new-project", "New Project") }; MockInitializedProjects(updatedListOfProjects); testSubject.ProjectResults.Should().BeEquivalentTo(updatedListOfProjects); } @@ -91,12 +89,7 @@ public void InitProjects_ResetsTheProjectResults() [TestMethod] public void InitProjects_SortsTheProjectResultsByName() { - var unsortedListOfProjects = new List - { - new("a-project", "Y Project"), - new("b-project", "X Project"), - new("c-project", "Z Project") - }; + var unsortedListOfProjects = new List { new("a-project", "Y Project"), new("b-project", "X Project"), new("c-project", "Z Project") }; MockInitializedProjects(unsortedListOfProjects); @@ -179,7 +172,7 @@ public void NoProjectExists_NoProjects_ReturnsTrue() [TestMethod] public void NoProjectExists_HasProjects_ReturnsFalse() { - MockInitializedProjects(AnInitialListOfProjects); + MockInitializedProjects(AnInitialListOfProjects); testSubject.NoProjectExists.Should().BeFalse(); } @@ -229,8 +222,8 @@ public async Task InitializeProjectWithProgressAsync_OnFailure_InitialServerProj [TestMethod] public async Task AdapterGetAllProjectsAsync_GettingServerConnectionSucceeded_CallsAdapterWithCredentialsForServerConnection() { - var expectedCredentials = Substitute.For(); - MockTrySonarQubeConnection(AConnectionInfo, success:true, expectedCredentials); + var expectedCredentials = Substitute.For(); + MockTrySonarQubeConnection(AConnectionInfo, success: true, expectedCredentials); await testSubject.AdapterGetAllProjectsAsync(); @@ -241,7 +234,7 @@ public async Task AdapterGetAllProjectsAsync_GettingServerConnectionSucceeded_Ca [TestMethod] public async Task AdapterGetAllProjectsAsync_GettingServerConnectionSucceeded_StoresServerConnection() { - MockTrySonarQubeConnection(AConnectionInfo, success: true, Substitute.For()); + MockTrySonarQubeConnection(AConnectionInfo, success: true, Substitute.For()); await testSubject.AdapterGetAllProjectsAsync(); @@ -251,7 +244,7 @@ public async Task AdapterGetAllProjectsAsync_GettingServerConnectionSucceeded_St [TestMethod] public async Task AdapterGetAllProjectsAsync_GettingServerConnectionFailed_ReturnsFailure() { - MockTrySonarQubeConnection(AConnectionInfo, success:false); + MockTrySonarQubeConnection(AConnectionInfo, success: false); var response = await testSubject.AdapterGetAllProjectsAsync(); @@ -268,7 +261,7 @@ public async Task AdapterGetAllProjectsAsync_GettingServerConnectionFailed_Retur public async Task AdapterGetAllProjectsAsync_ReturnsResponseFromAdapter(bool expectedResponse) { MockTrySonarQubeConnection(AConnectionInfo, success: true); - var expectedServerProjects = new List{new("proj1", "name1"), new("proj2", "name2") }; + var expectedServerProjects = new List { new("proj1", "name1"), new("proj2", "name2") }; slCoreConnectionAdapter.GetAllProjectsAsync(Arg.Any()) .Returns(new AdapterResponseWithData>(expectedResponse, expectedServerProjects)); @@ -304,7 +297,7 @@ private void MockInitializedProjects(List serverProjects) testSubject.InitProjects(new AdapterResponseWithData>(true, serverProjects)); } - private void MockTrySonarQubeConnection(ConnectionInfo connectionInfo, bool success = true, ICredentials expectedCredentials = null) + private void MockTrySonarQubeConnection(ConnectionInfo connectionInfo, bool success = true, IConnectionCredentials expectedCredentials = null) { serverConnectionsRepositoryAdapter.TryGet(connectionInfo, out _).Returns(callInfo => { @@ -321,7 +314,7 @@ private ProjectSelectionViewModel CreateInitializedTestSubjectWithNotMockedProgr } private ProjectSelectionViewModel CreateTestSubjectWithNotMockedProgress() - { + { return new ProjectSelectionViewModel(AConnectionInfo, connectedModeServices, new ProgressReporterViewModel(logger)); } } diff --git a/src/ConnectedMode/Binding/IUnintrusiveBindingController.cs b/src/ConnectedMode/Binding/IUnintrusiveBindingController.cs index 96d2432899..86330146bf 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 @@ -28,9 +29,10 @@ namespace SonarLint.VisualStudio.ConnectedMode.Binding public interface IBindingController { Task BindAsync(BoundServerProject project, CancellationToken cancellationToken); + bool Unbind(string localBindingKey); } - + internal interface IUnintrusiveBindingController { Task BindAsync(BoundServerProject project, IProgress progress, CancellationToken token); @@ -47,7 +49,11 @@ internal class UnintrusiveBindingController : IUnintrusiveBindingController, IBi private readonly ISolutionBindingRepository solutionBindingRepository; [ImportingConstructor] - public UnintrusiveBindingController(IBindingProcessFactory bindingProcessFactory, ISonarQubeService sonarQubeService, IActiveSolutionChangedHandler activeSolutionChangedHandler, ISolutionBindingRepository solutionBindingRepository) + public UnintrusiveBindingController( + IBindingProcessFactory bindingProcessFactory, + ISonarQubeService sonarQubeService, + IActiveSolutionChangedHandler activeSolutionChangedHandler, + ISolutionBindingRepository solutionBindingRepository) { this.bindingProcessFactory = bindingProcessFactory; this.sonarQubeService = sonarQubeService; @@ -57,7 +63,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/BindingJsonModelConverter.cs b/src/ConnectedMode/Persistence/BindingJsonModelConverter.cs index 763724a04b..24c7fe6e2e 100644 --- a/src/ConnectedMode/Persistence/BindingJsonModelConverter.cs +++ b/src/ConnectedMode/Persistence/BindingJsonModelConverter.cs @@ -27,8 +27,10 @@ namespace SonarLint.VisualStudio.ConnectedMode.Persistence; internal interface IBindingJsonModelConverter { BoundServerProject ConvertFromModel(BindingJsonModel bindingJsonModel, ServerConnection connection, string localBindingKey); + BindingJsonModel ConvertToModel(BoundServerProject binding); - BoundSonarQubeProject ConvertFromModelToLegacy(BindingJsonModel bindingJsonModel, ICredentials credentials); + + BoundSonarQubeProject ConvertFromModelToLegacy(BindingJsonModel bindingJsonModel, IConnectionCredentials credentials); } [Export(typeof(IBindingJsonModelConverter))] @@ -36,10 +38,7 @@ internal interface IBindingJsonModelConverter internal class BindingJsonModelConverter : IBindingJsonModelConverter { public BoundServerProject ConvertFromModel(BindingJsonModel bindingJsonModel, ServerConnection connection, string localBindingKey) => - new(localBindingKey, bindingJsonModel.ProjectKey, connection) - { - Profiles = bindingJsonModel.Profiles - }; + new(localBindingKey, bindingJsonModel.ProjectKey, connection) { Profiles = bindingJsonModel.Profiles }; public BindingJsonModel ConvertToModel(BoundServerProject binding) => new() @@ -54,13 +53,10 @@ public BindingJsonModel ConvertToModel(BoundServerProject binding) => : null }; - public BoundSonarQubeProject ConvertFromModelToLegacy(BindingJsonModel bindingJsonModel, ICredentials credentials) => - new(bindingJsonModel.ServerUri, - bindingJsonModel.ProjectKey, - bindingJsonModel.ProjectName, - credentials, - bindingJsonModel.Organization) - { - Profiles = bindingJsonModel.Profiles - }; + public BoundSonarQubeProject ConvertFromModelToLegacy(BindingJsonModel bindingJsonModel, IConnectionCredentials credentials) => + new(bindingJsonModel.ServerUri, + bindingJsonModel.ProjectKey, + bindingJsonModel.ProjectName, + credentials, + bindingJsonModel.Organization) { Profiles = bindingJsonModel.Profiles }; } 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/ConnectedMode/Persistence/ICredentialsExtensionMethods.cs b/src/ConnectedMode/Persistence/ICredentialsExtensionMethods.cs new file mode 100644 index 0000000000..e66b6d5e65 --- /dev/null +++ b/src/ConnectedMode/Persistence/ICredentialsExtensionMethods.cs @@ -0,0 +1,51 @@ +/* + * 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 Microsoft.Alm.Authentication; +using SonarLint.VisualStudio.Core.Binding; +using SonarQube.Client.Helpers; +using SonarQube.Client.Models; + +namespace SonarLint.VisualStudio.ConnectedMode.Persistence; + +internal static class CredentialsExtensionMethods +{ + internal static Credential ToCredential(this IConnectionCredentials credentials) => + credentials switch + { + UsernameAndPasswordCredentials basicAuthCredentials => new Credential(basicAuthCredentials.UserName, basicAuthCredentials.Password.ToUnsecureString()), + // the ICredentialStoreService requires a username, but then proceeds to store the username as the password and a hard-coded string as the username + TokenAuthCredentials tokenAuthCredentials => new Credential(tokenAuthCredentials.Token.ToUnsecureString(), string.Empty), + _ => throw new NotSupportedException($"Unexpected credentials type: {credentials?.GetType()}") + }; + + internal static IConnectionCredentials ToICredentials(this Credential credential) + { + if (credential is null) + { + return null; + } + if (credential.Password == string.Empty) + { + return new TokenAuthCredentials(credential.Username.ToSecureString()); + } + return new UsernameAndPasswordCredentials(credential.Username, credential.Password.ToSecureString()); + } +} diff --git a/src/ConnectedMode/Persistence/ISolutionBindingCredentialsLoader.cs b/src/ConnectedMode/Persistence/ISolutionBindingCredentialsLoader.cs index d3bfb0c31b..38bd8ab847 100644 --- a/src/ConnectedMode/Persistence/ISolutionBindingCredentialsLoader.cs +++ b/src/ConnectedMode/Persistence/ISolutionBindingCredentialsLoader.cs @@ -18,15 +18,16 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -using System; -using SonarLint.VisualStudio.Core.Binding; +using SonarQube.Client.Models; namespace SonarLint.VisualStudio.ConnectedMode.Persistence { interface ISolutionBindingCredentialsLoader { void DeleteCredentials(Uri boundServerUri); - ICredentials Load(Uri boundServerUri); - void Save(ICredentials credentials, Uri boundServerUri); + + IConnectionCredentials Load(Uri boundServerUri); + + void Save(IConnectionCredentials credentials, Uri boundServerUri); } } diff --git a/src/ConnectedMode/Persistence/ServerConnectionsRepository.cs b/src/ConnectedMode/Persistence/ServerConnectionsRepository.cs index 7554307cfc..12d671918e 100644 --- a/src/ConnectedMode/Persistence/ServerConnectionsRepository.cs +++ b/src/ConnectedMode/Persistence/ServerConnectionsRepository.cs @@ -25,10 +25,10 @@ using System.IO.Abstractions; using SonarLint.VisualStudio.Core.Persistence; using SonarLint.VisualStudio.ConnectedMode.Binding; +using SonarQube.Client.Models; namespace SonarLint.VisualStudio.ConnectedMode.Persistence; - [Export(typeof(IServerConnectionsRepository))] [PartCreationPolicy(CreationPolicy.Shared)] internal class ServerConnectionsRepository : IServerConnectionsRepository @@ -56,7 +56,9 @@ public ServerConnectionsRepository( new SolutionBindingCredentialsLoader(credentialStoreService), EnvironmentVariableProvider.Instance, new FileSystem(), - logger) { } + logger) + { + } internal /* for testing */ ServerConnectionsRepository( IJsonFileHandler jsonFileHandle, @@ -84,7 +86,6 @@ public bool TryGet(string connectionId, out ServerConnection serverConnection) serverConnection.Credentials = credentialsLoader.Load(serverConnection.CredentialsUri); return true; - } public bool TryGetAll(out IReadOnlyList serverConnections) @@ -122,7 +123,7 @@ public bool TryUpdateSettingsById(string connectionId, ServerConnectionSettings return SafeUpdateConnectionsFile(connections => TryUpdateConnectionSettings(connections, connectionId, connectionSettings)); } - public bool TryUpdateCredentialsById(string connectionId, ICredentials credentials) + public bool TryUpdateCredentialsById(string connectionId, IConnectionCredentials credentials) { try { @@ -171,7 +172,6 @@ private bool TryAddConnection(List connections, ServerConnecti return false; } - private void TryDeleteCredentials(ServerConnection removedConnection) { try @@ -214,7 +214,7 @@ private List ReadServerConnectionsFromFile() // file not existing should not be treated as an error, as it will be created at the first write return []; } - catch(Exception ex) when (!ErrorHandler.IsCriticalException(ex)) + catch (Exception ex) when (!ErrorHandler.IsCriticalException(ex)) { logger.WriteLine($"Failed reading the {ConnectionsFileName}: {ex.Message}"); } @@ -251,5 +251,6 @@ private bool SafeUpdateConnectionsFile(Func, bool> tryUpd } private void OnConnectionChanged() => ConnectionChanged?.Invoke(this, EventArgs.Empty); + private void OnCredentialsChanged(ServerConnection serverConnection) => CredentialsChanged?.Invoke(this, new ServerConnectionUpdatedEventArgs(serverConnection)); } diff --git a/src/ConnectedMode/Persistence/SolutionBindingCredentialsLoader.cs b/src/ConnectedMode/Persistence/SolutionBindingCredentialsLoader.cs index 6232873efd..36df1fa57c 100644 --- a/src/ConnectedMode/Persistence/SolutionBindingCredentialsLoader.cs +++ b/src/ConnectedMode/Persistence/SolutionBindingCredentialsLoader.cs @@ -18,10 +18,8 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -using Microsoft.Alm.Authentication; using SonarLint.VisualStudio.ConnectedMode.Binding; -using SonarLint.VisualStudio.Core.Binding; -using SonarQube.Client.Helpers; +using SonarQube.Client.Models; namespace SonarLint.VisualStudio.ConnectedMode.Persistence { @@ -36,14 +34,14 @@ public SolutionBindingCredentialsLoader(ICredentialStoreService store) public void DeleteCredentials(Uri boundServerUri) { - if(boundServerUri == null) + if (boundServerUri == null) { return; } store.DeleteCredentials(boundServerUri); } - public ICredentials Load(Uri boundServerUri) + public IConnectionCredentials Load(Uri boundServerUri) { if (boundServerUri == null) { @@ -51,27 +49,17 @@ public ICredentials Load(Uri boundServerUri) } var credentials = store.ReadCredentials(boundServerUri); - return credentials == null - ? null - : new BasicAuthCredentials(credentials.Username, credentials.Password.ToSecureString()); + return credentials.ToICredentials(); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", - "S3215:\"interface\" instances should not be cast to concrete types", - Justification = "Casting as BasicAuthCredentials is because it's the only credential type we support. Once we add more we need to think again on how to refactor the code to avoid this", - Scope = "member", - Target = "~M:SonarLint.VisualStudio.Integration.Persistence.FileBindingSerializer.WriteBindingInformation(System.String,SonarLint.VisualStudio.Integration.Persistence.BoundProject)~System.Boolean")] - public void Save(ICredentials credentials, Uri boundServerUri) + public void Save(IConnectionCredentials credentials, Uri boundServerUri) { - if (boundServerUri == null || !(credentials is BasicAuthCredentials basicCredentials)) + if (boundServerUri == null || credentials is null) { return; } - Debug.Assert(basicCredentials.UserName != null, "User name is not expected to be null"); - Debug.Assert(basicCredentials.Password != null, "Password name is not expected to be null"); - - var credentialToSave = new Credential(basicCredentials.UserName, basicCredentials.Password.ToUnsecureString()); + var credentialToSave = credentials.ToCredential(); store.WriteCredentials(boundServerUri, credentialToSave); } } diff --git a/src/Core/Binding/ICredentials.cs b/src/ConnectedMode/Persistence/TokenAuthCredentials.cs similarity index 67% rename from src/Core/Binding/ICredentials.cs rename to src/ConnectedMode/Persistence/TokenAuthCredentials.cs index 4a488970cd..95873f4d8e 100644 --- a/src/Core/Binding/ICredentials.cs +++ b/src/ConnectedMode/Persistence/TokenAuthCredentials.cs @@ -18,13 +18,17 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -using System; +using System.Security; +using SonarQube.Client.Helpers; using SonarQube.Client.Models; -namespace SonarLint.VisualStudio.Core.Binding +namespace SonarLint.VisualStudio.ConnectedMode.Persistence; + +internal sealed class TokenAuthCredentials(SecureString token) : ITokenCredentials { - public interface ICredentials - { - ConnectionInformation CreateConnectionInformation(Uri serverUri); - } + public SecureString Token { get; } = token ?? throw new ArgumentNullException(nameof(token)); + + public void Dispose() => Token?.Dispose(); + + public object Clone() => new TokenAuthCredentials(Token.CopyAsReadOnly()); } diff --git a/src/ConnectedMode/Persistence/BasicAuthCredentials.cs b/src/ConnectedMode/Persistence/UsernameAndPasswordCredentials.cs similarity index 59% rename from src/ConnectedMode/Persistence/BasicAuthCredentials.cs rename to src/ConnectedMode/Persistence/UsernameAndPasswordCredentials.cs index d13d6255dc..7cc50a2581 100644 --- a/src/ConnectedMode/Persistence/BasicAuthCredentials.cs +++ b/src/ConnectedMode/Persistence/UsernameAndPasswordCredentials.cs @@ -18,28 +18,19 @@ * 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 UsernameAndPasswordCredentials(string userName, SecureString password) : IUsernameAndPasswordCredentials { - 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 UsernameAndPasswordCredentials(UserName, Password.CopyAsReadOnly()); } diff --git a/src/ConnectedMode/ServerConnectionsRepositoryAdapter.cs b/src/ConnectedMode/ServerConnectionsRepositoryAdapter.cs index 5e684787ea..39ab161614 100644 --- a/src/ConnectedMode/ServerConnectionsRepositoryAdapter.cs +++ b/src/ConnectedMode/ServerConnectionsRepositoryAdapter.cs @@ -24,16 +24,22 @@ using SonarLint.VisualStudio.ConnectedMode.UI.Credentials; using SonarLint.VisualStudio.Core.Binding; using SonarQube.Client.Helpers; +using SonarQube.Client.Models; namespace SonarLint.VisualStudio.ConnectedMode; public interface IServerConnectionsRepositoryAdapter { bool TryGetAllConnections(out List connections); + bool TryGetAllConnectionsInfo(out List connectionInfos); + bool TryRemoveConnection(ConnectionInfo connectionInfo); + bool TryAddConnection(Connection connection, ICredentialsModel credentialsModel); + bool TryUpdateCredentials(Connection connection, ICredentialsModel credentialsModel); + bool TryGet(ConnectionInfo connectionInfo, out ServerConnection serverConnection); } @@ -61,7 +67,7 @@ public bool TryAddConnection(Connection connection, ICredentialsModel credential serverConnection.Credentials = MapCredentials(credentialsModel); return serverConnectionsRepository.TryAdd(serverConnection); } - + public bool TryUpdateCredentials(Connection connection, ICredentialsModel credentialsModel) { var serverConnection = MapConnection(connection); @@ -97,14 +103,14 @@ private static ServerConnection MapConnection(Connection connection) return new ServerConnection.SonarQube(new Uri(connection.Info.Id), new ServerConnectionSettings(connection.EnableSmartNotifications)); } - private static ICredentials MapCredentials(ICredentialsModel credentialsModel) + private static IConnectionCredentials MapCredentials(ICredentialsModel credentialsModel) { switch (credentialsModel) { case TokenCredentialsModel tokenCredentialsModel: - return new BasicAuthCredentials(tokenCredentialsModel.Token.ToUnsecureString(), new SecureString()); + return new TokenAuthCredentials(tokenCredentialsModel.Token); case UsernamePasswordModel usernameCredentialsModel: - return new BasicAuthCredentials(usernameCredentialsModel.Username, usernameCredentialsModel.Password); + return new UsernameAndPasswordCredentials(usernameCredentialsModel.Username, usernameCredentialsModel.Password); default: return null; } diff --git a/src/ConnectedMode/SlCoreConnectionAdapter.cs b/src/ConnectedMode/SlCoreConnectionAdapter.cs index 169433bd83..7b6e438050 100644 --- a/src/ConnectedMode/SlCoreConnectionAdapter.cs +++ b/src/ConnectedMode/SlCoreConnectionAdapter.cs @@ -33,28 +33,35 @@ using SonarLint.VisualStudio.SLCore.Service.Connection; using SonarLint.VisualStudio.SLCore.Service.Connection.Models; using SonarQube.Client.Helpers; +using SonarQube.Client.Models; namespace SonarLint.VisualStudio.ConnectedMode; public interface ISlCoreConnectionAdapter { Task ValidateConnectionAsync(ConnectionInfo connectionInfo, ICredentialsModel credentialsModel); + Task>> GetOrganizationsAsync(ICredentialsModel credentialsModel); + Task> GetServerProjectByKeyAsync(ServerConnection serverConnection, string serverProjectKey); + Task>> GetAllProjectsAsync(ServerConnection serverConnection); + Task>> FuzzySearchProjectsAsync(ServerConnection serverConnection, string searchTerm); } public class AdapterResponseWithData(bool success, T responseData) : IResponseStatus { public AdapterResponseWithData() : this(false, default) { } + public bool Success { get; init; } = success; public T ResponseData { get; } = responseData; } public class AdapterResponse(bool success) : IResponseStatus { - public AdapterResponse(): this(false){} + public AdapterResponse() : this(false) { } + public bool Success { get; } = success; } @@ -78,7 +85,7 @@ public SlCoreConnectionAdapter(ISLCoreServiceProvider serviceProvider, IThreadHa public async Task ValidateConnectionAsync(ConnectionInfo connectionInfo, ICredentialsModel credentialsModel) { var credentials = credentialsModel.ToICredentials(); - + var validateConnectionParams = new ValidateConnectionParams(GetTransientConnectionDto(connectionInfo, credentials)); return await ValidateConnectionAsync(validateConnectionParams); } @@ -111,7 +118,7 @@ public Task>> GetOrganizations public Task> GetServerProjectByKeyAsync(ServerConnection serverConnection, string serverProjectKey) { var failedResponse = new AdapterResponseWithData(false, null); - + return threadHandling.RunOnBackgroundThread(async () => { if (!TryGetConnectionConfigurationSlCoreService(out var connectionConfigurationSlCoreService)) @@ -129,7 +136,7 @@ public Task> GetServerProjectByKeyAsync(S logger.LogVerbose(Resources.GetServerProjectByKey_ProjectNotFound, serverProjectKey); return failedResponse; } - + return new AdapterResponseWithData(true, new ServerProject(serverProjectKey, response.projectNamesByKey[serverProjectKey])); } catch (Exception ex) @@ -229,11 +236,11 @@ private bool TryGetConnectionConfigurationSlCoreService(out IConnectionConfigura logger.LogVerbose($"[{nameof(IConnectionConfigurationSLCoreService)}] {SLCoreStrings.ServiceProviderNotInitialized}"); return false; } - - private static Either GetTransientConnectionDto(ConnectionInfo connectionInfo, ICredentials credentials) + + private static Either GetTransientConnectionDto(ConnectionInfo connectionInfo, IConnectionCredentials credentials) { var credentialsDto = MapCredentials(credentials); - + return connectionInfo.ServerType switch { ConnectionServerType.SonarQube => Either.CreateLeft( @@ -247,7 +254,7 @@ private static Either GetTransientConnectionDto(ServerConnection serverConnection) { var credentials = MapCredentials(serverConnection.Credentials); - + return serverConnection switch { ServerConnection.SonarQube sonarQubeConnection => Either.CreateLeft( @@ -267,17 +274,12 @@ private static Either GetEitherForUsernamePasswor { return Either.CreateRight(new UsernamePasswordDto(username, password)); } - - private static Either MapCredentials(ICredentials credentials) - { - if (credentials == null) + + private static Either MapCredentials(IConnectionCredentials credentials) => + credentials switch { - throw new ArgumentException($"Unexpected {nameof(ICredentialsModel)} argument"); - } - - var basicAuthCredentials = (BasicAuthCredentials) credentials; - return basicAuthCredentials.Password?.Length > 0 - ? GetEitherForUsernamePassword(basicAuthCredentials.UserName, basicAuthCredentials.Password.ToUnsecureString()) - : GetEitherForToken(basicAuthCredentials.UserName); - } + UsernameAndPasswordCredentials basicAuthCredentials => GetEitherForUsernamePassword(basicAuthCredentials.UserName, basicAuthCredentials.Password.ToUnsecureString()), + TokenAuthCredentials tokenAuthCredentials => GetEitherForToken(tokenAuthCredentials.Token.ToUnsecureString()), + _ => throw new ArgumentException($"Unexpected {nameof(ICredentialsModel)} argument") + }; } diff --git a/src/ConnectedMode/UI/Credentials/ICredentialsModel.cs b/src/ConnectedMode/UI/Credentials/ICredentialsModel.cs index 2e14786ec5..2c32e0ff84 100644 --- a/src/ConnectedMode/UI/Credentials/ICredentialsModel.cs +++ b/src/ConnectedMode/UI/Credentials/ICredentialsModel.cs @@ -20,33 +20,26 @@ using System.Security; using SonarLint.VisualStudio.ConnectedMode.Persistence; -using SonarLint.VisualStudio.Core.Binding; -using SonarQube.Client.Helpers; +using SonarQube.Client.Models; namespace SonarLint.VisualStudio.ConnectedMode.UI.Credentials; public interface ICredentialsModel { - ICredentials ToICredentials(); + IConnectionCredentials ToICredentials(); } public class TokenCredentialsModel(SecureString token) : ICredentialsModel { public SecureString Token { get; } = token; - public ICredentials ToICredentials() - { - return new BasicAuthCredentials(Token.ToUnsecureString(), new SecureString()); - } + public IConnectionCredentials ToICredentials() => new TokenAuthCredentials(Token); } public class UsernamePasswordModel(string username, SecureString password) : ICredentialsModel { public string Username { get; } = username; public SecureString Password { get; } = password; - - public ICredentials ToICredentials() - { - return new BasicAuthCredentials(Username, Password); - } + + public IConnectionCredentials ToICredentials() => new UsernameAndPasswordCredentials(Username, Password); } diff --git a/src/Core.UnitTests/Binding/ServerConnectionTests.cs b/src/Core.UnitTests/Binding/ServerConnectionTests.cs index af266ddcec..9c929c5e1d 100644 --- a/src/Core.UnitTests/Binding/ServerConnectionTests.cs +++ b/src/Core.UnitTests/Binding/ServerConnectionTests.cs @@ -20,7 +20,6 @@ using SonarLint.VisualStudio.Core.Binding; using SonarQube.Client.Models; -using ICredentials = SonarLint.VisualStudio.Core.Binding.ICredentials; namespace SonarLint.VisualStudio.Core.UnitTests.Binding; @@ -29,7 +28,7 @@ public class ServerConnectionTests { private static readonly Uri Localhost = new("http://localhost:5000"); private const string Org = "myOrg"; - + [TestMethod] public void Ctor_SonarCloud_NullOrganization_Throws() { @@ -37,7 +36,7 @@ public void Ctor_SonarCloud_NullOrganization_Throws() act.Should().Throw(); } - + [TestMethod] public void Ctor_SonarCloud_NullSettings_SetDefault() { @@ -45,7 +44,7 @@ public void Ctor_SonarCloud_NullSettings_SetDefault() sonarCloud.Settings.Should().BeSameAs(ServerConnection.DefaultSettings); } - + [TestMethod] public void Ctor_SonarCloud_NullCredentials_SetsNull() { @@ -53,12 +52,12 @@ public void Ctor_SonarCloud_NullCredentials_SetsNull() sonarCloud.Credentials.Should().BeNull(); } - + [TestMethod] public void Ctor_SonarCloud_SetsProperties() { var serverConnectionSettings = new ServerConnectionSettings(false); - var credentials = Substitute.For(); + var credentials = Substitute.For(); var sonarCloud = new ServerConnection.SonarCloud(Org, serverConnectionSettings, credentials); sonarCloud.Id.Should().Be($"https://sonarcloud.io/organizations/{Org}"); @@ -68,7 +67,7 @@ public void Ctor_SonarCloud_SetsProperties() sonarCloud.Credentials.Should().BeSameAs(credentials); sonarCloud.CredentialsUri.Should().Be(new Uri($"https://sonarcloud.io/organizations/{Org}")); } - + [TestMethod] public void Ctor_SonarQube_NullUri_Throws() { @@ -76,7 +75,7 @@ public void Ctor_SonarQube_NullUri_Throws() act.Should().Throw(); } - + [TestMethod] public void Ctor_SonarQube_NullSettings_SetDefault() { @@ -84,7 +83,7 @@ public void Ctor_SonarQube_NullSettings_SetDefault() sonarQube.Settings.Should().BeSameAs(ServerConnection.DefaultSettings); } - + [TestMethod] public void Ctor_SonarQube_NullCredentials_SetsNull() { @@ -92,12 +91,12 @@ public void Ctor_SonarQube_NullCredentials_SetsNull() sonarQube.Credentials.Should().BeNull(); } - + [TestMethod] public void Ctor_SonarQube_SetsProperties() { var serverConnectionSettings = new ServerConnectionSettings(false); - var credentials = Substitute.For(); + var credentials = Substitute.For(); var sonarQube = new ServerConnection.SonarQube(Localhost, serverConnectionSettings, credentials); sonarQube.Id.Should().Be(Localhost.ToString()); @@ -110,35 +109,35 @@ public void Ctor_SonarQube_SetsProperties() [TestMethod] public void FromBoundSonarQubeProject_SonarQubeConnection_ConvertedCorrectly() { - var credentials = Substitute.For(); + var credentials = Substitute.For(); var expectedConnection = new ServerConnection.SonarQube(Localhost, credentials: credentials); var connection = ServerConnection.FromBoundSonarQubeProject(new BoundSonarQubeProject(Localhost, "any", "any", credentials)); - + connection.Should().BeEquivalentTo(expectedConnection, options => options.ComparingByMembers()); } - + [TestMethod] public void FromBoundSonarQubeProject_SonarCloudConnection_ConvertedCorrectly() { var uri = new Uri("https://sonarcloud.io"); var organization = "org"; - var credentials = Substitute.For(); + var credentials = Substitute.For(); var expectedConnection = new ServerConnection.SonarCloud(organization, credentials: credentials); var connection = ServerConnection.FromBoundSonarQubeProject(new BoundSonarQubeProject(uri, "any", "any", credentials, new SonarQubeOrganization(organization, null))); - + connection.Should().BeEquivalentTo(expectedConnection, options => options.ComparingByMembers()); } - + [TestMethod] public void FromBoundSonarQubeProject_InvalidConnection_ReturnsNull() { - var connection = ServerConnection.FromBoundSonarQubeProject(new BoundSonarQubeProject(){ ProjectKey = "project"}); + var connection = ServerConnection.FromBoundSonarQubeProject(new BoundSonarQubeProject() { ProjectKey = "project" }); connection.Should().BeNull(); } - + [TestMethod] public void FromBoundSonarQubeProject_NullConnection_ReturnsNull() { diff --git a/src/Core/Binding/BoundSonarQubeProject.cs b/src/Core/Binding/BoundSonarQubeProject.cs index e66ea8434d..09d2b108f2 100644 --- a/src/Core/Binding/BoundSonarQubeProject.cs +++ b/src/Core/Binding/BoundSonarQubeProject.cs @@ -31,8 +31,12 @@ public BoundSonarQubeProject() { } - public BoundSonarQubeProject(Uri serverUri, string projectKey, string projectName, - ICredentials credentials = null, SonarQubeOrganization organization = null) + public BoundSonarQubeProject( + Uri serverUri, + string projectKey, + string projectName, + IConnectionCredentials credentials = null, + SonarQubeOrganization organization = null) : this() { if (serverUri == null) @@ -60,6 +64,6 @@ public BoundSonarQubeProject(Uri serverUri, string projectKey, string projectNam public Dictionary Profiles { get; set; } [JsonIgnore] - public ICredentials Credentials { get; set; } + public IConnectionCredentials Credentials { get; set; } } } diff --git a/src/Core/Binding/BoundSonarQubeProjectExtensions.cs b/src/Core/Binding/BoundSonarQubeProjectExtensions.cs index abbcc63204..29af790919 100644 --- a/src/Core/Binding/BoundSonarQubeProjectExtensions.cs +++ b/src/Core/Binding/BoundSonarQubeProjectExtensions.cs @@ -32,14 +32,12 @@ 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; } - + public static ConnectionInformation CreateConnectionInformation(this BoundServerProject binding) { if (binding == null) @@ -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/IServerConnectionsRepository.cs b/src/Core/Binding/IServerConnectionsRepository.cs index 22e988257a..7c695e6333 100644 --- a/src/Core/Binding/IServerConnectionsRepository.cs +++ b/src/Core/Binding/IServerConnectionsRepository.cs @@ -18,17 +18,26 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +using SonarQube.Client.Models; + namespace SonarLint.VisualStudio.Core.Binding; public interface IServerConnectionsRepository { bool TryGet(string connectionId, out ServerConnection serverConnection); + bool TryGetAll(out IReadOnlyList serverConnections); + bool TryAdd(ServerConnection connectionToAdd); + bool TryDelete(string connectionId); + bool TryUpdateSettingsById(string connectionId, ServerConnectionSettings connectionSettings); - bool TryUpdateCredentialsById(string connectionId, ICredentials credentials); + + bool TryUpdateCredentialsById(string connectionId, IConnectionCredentials credentials); + bool ConnectionsFileExists(); + event EventHandler ConnectionChanged; event EventHandler CredentialsChanged; } @@ -42,4 +51,3 @@ public ServerConnectionUpdatedEventArgs(ServerConnection serverConnection) public ServerConnection ServerConnection { get; } } - diff --git a/src/Core/Binding/ServerConnection.cs b/src/Core/Binding/ServerConnection.cs index f4d2f5c2ee..1c4df106ad 100644 --- a/src/Core/Binding/ServerConnection.cs +++ b/src/Core/Binding/ServerConnection.cs @@ -19,17 +19,18 @@ */ using System.IO; +using SonarQube.Client.Models; namespace SonarLint.VisualStudio.Core.Binding; public abstract class ServerConnection { internal static readonly ServerConnectionSettings DefaultSettings = new(true); - + public string Id { get; } public ServerConnectionSettings Settings { get; set; } - public ICredentials Credentials { get; set; } - + public IConnectionCredentials Credentials { get; set; } + public abstract Uri ServerUri { get; } public abstract Uri CredentialsUri { get; } @@ -41,7 +42,7 @@ public static ServerConnection FromBoundSonarQubeProject(BoundSonarQubeProject b _ => null }; - private ServerConnection(string id, ServerConnectionSettings settings = null, ICredentials credentials = null) + private ServerConnection(string id, ServerConnectionSettings settings = null, IConnectionCredentials credentials = null) { Id = id ?? throw new ArgumentNullException(nameof(id)); Settings = settings ?? DefaultSettings; @@ -51,8 +52,8 @@ private ServerConnection(string id, ServerConnectionSettings settings = null, IC public sealed class SonarCloud : ServerConnection { private static readonly string SonarCloudUrl = CoreStrings.SonarCloudUrl; - - public SonarCloud(string organizationKey, ServerConnectionSettings settings = null, ICredentials credentials = null) + + public SonarCloud(string organizationKey, ServerConnectionSettings settings = null, IConnectionCredentials credentials = null) : base(OrganizationKeyToId(organizationKey), settings, credentials) { OrganizationKey = organizationKey; @@ -60,8 +61,8 @@ public SonarCloud(string organizationKey, ServerConnectionSettings settings = nu } public string OrganizationKey { get; } - - public override Uri ServerUri => new (SonarCloudUrl); + + public override Uri ServerUri => new(SonarCloudUrl); public override Uri CredentialsUri { get; } private static string OrganizationKeyToId(string organizationKey) @@ -74,8 +75,8 @@ private static string OrganizationKeyToId(string organizationKey) return $"{SonarCloudUrl}/organizations/{organizationKey}"; } } - - public sealed class SonarQube(Uri serverUri, ServerConnectionSettings settings = null, ICredentials credentials = null) + + public sealed class SonarQube(Uri serverUri, ServerConnectionSettings settings = null, IConnectionCredentials credentials = null) : ServerConnection(serverUri?.ToString(), settings, credentials) { public override Uri ServerUri { get; } = serverUri; diff --git a/src/Integration.UnitTests/Service/ConnectionInformationTests.cs b/src/Integration.UnitTests/Service/ConnectionInformationTests.cs index c4ccb8625f..1730e22203 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 UsernameAndPasswordCredentials(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"); + ((UsernameAndPasswordCredentials)testSubject.Credentials).Password.ToUnsecureString().Should().Be(passwordUnsecure, "Password doesn't match"); + ((UsernameAndPasswordCredentials)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(() => ((UsernameAndPasswordCredentials)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"); + ((UsernameAndPasswordCredentials)testSubject2.Credentials).Password.ToUnsecureString().Should().Be(passwordUnsecure, "Password doesn't match"); + ((UsernameAndPasswordCredentials)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 UsernameAndPasswordCredentials("user", "pwd".ToSecureString()))); } } } diff --git a/src/Integration/MefServices/MefSonarQubeService.cs b/src/Integration/MefServices/MefSonarQubeService.cs index 22ec5f59a9..5c829a5d59 100644 --- a/src/Integration/MefServices/MefSonarQubeService.cs +++ b/src/Integration/MefServices/MefSonarQubeService.cs @@ -58,11 +58,11 @@ public MefSonarQubeService(ILogger logger) this.threadHandling = threadHandling; } - protected override async Task InvokeUncheckedRequestAsync(Action configure, CancellationToken token) + protected override async Task InvokeUncheckedRequestAsync(Action configure, HttpClient httpClientParam, CancellationToken token) { CodeMarkers.Instance.WebClientCallStart(typeof(TRequest).Name); - Func> asyncMethod = () => base.InvokeUncheckedRequestAsync(configure, token); + Func> asyncMethod = () => base.InvokeUncheckedRequestAsync(configure, httpClientParam, token); var result = await threadHandling.RunOnBackgroundThread(asyncMethod); @@ -84,14 +84,11 @@ public void Debug(string message) => // This will only be logged if an env var is set logger.LogVerbose(message); - public void Error(string message) => - logger.WriteLine($"ERROR: {message}"); + public void Error(string message) => logger.WriteLine($"ERROR: {message}"); - public void Info(string message) => - logger.WriteLine($"{message}"); + public void Info(string message) => logger.WriteLine($"{message}"); - public void Warning(string message) => - logger.WriteLine($"WARNING: {message}"); + public void Warning(string message) => logger.WriteLine($"WARNING: {message}"); } } } diff --git a/src/SonarQube.Client.Tests/CallRealServerTestHarness.cs b/src/SonarQube.Client.Tests/CallRealServerTestHarness.cs index a3e6c116a5..0640c69b7b 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 IUsernameAndPasswordCredentials 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..cea58e8408 100644 --- a/src/SonarQube.Client.Tests/Helpers/AuthenticationHeaderFactoryTests.cs +++ b/src/SonarQube.Client.Tests/Helpers/AuthenticationHeaderFactoryTests.cs @@ -18,18 +18,21 @@ * 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 Token = "token"; + private const string Username = "username"; + [TestMethod] - public void AuthenticationHeaderHelper_GetAuthToken() + public void GetAuthToken_ReturnsExpectedString() { // Invalid input string user = "hello:"; @@ -59,8 +62,122 @@ public void AuthenticationHeaderHelper_GetAuthToken() AuthenticationHeaderFactory.GetBasicAuthToken(user, password.ToSecureString())); } - private void AssertAreEqualUserNameAndPassword(string expectedUser, string expectedPassword, + [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_PasswordIsNull_Throws() + { + var credentials = MockBasicAuthCredentials(Username, null); + + var act = () => AuthenticationHeaderFactory.Create(credentials); + + act.Should().Throw(); + } + + [TestMethod] + public void Create_BasicAuth_PasswordIsEmpty_Throws() + { + var credentials = MockBasicAuthCredentials(Username, "".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); + } + + [TestMethod] + public void Create_BearerSupported_TokenIsNull_Throws() + { + var credentials = MockTokenCredentials(null); + + var act = () => AuthenticationHeaderFactory.Create(credentials); + + act.Should().Throw(); + } + + [TestMethod] + public void Create_BearerSupported_TokenIsEmpty_Throws() + { + var credentials = MockTokenCredentials("".ToSecureString()); + + var act = () => AuthenticationHeaderFactory.Create(credentials); + + act.Should().Throw(); + } + + [TestMethod] + public void Create_BearerSupported_TokenIsFilled_ReturnsBearerScheme() + { + var credentials = MockTokenCredentials(Token.ToSecureString()); + + var authenticationHeaderValue = AuthenticationHeaderFactory.Create(credentials); + + authenticationHeaderValue.Scheme.Should().Be("Bearer"); + authenticationHeaderValue.Parameter.Should().Be(Token); + } + + [TestMethod] + public void Create_BearerNotSupported_TokenIsFilled_ReturnsBasicScheme() + { + var credentials = MockTokenCredentials(Token.ToSecureString()); + + var authenticationHeaderValue = AuthenticationHeaderFactory.Create(credentials, shouldUseBearer: false); + + authenticationHeaderValue.Scheme.Should().Be("Basic"); + AssertAreEqualUserNameAndPassword(Token, string.Empty, authenticationHeaderValue.Parameter); + } + + [TestMethod] + public void Create_BearerNotSupported_TokenIsEmpty_Throws() + { + var credentials = MockTokenCredentials("".ToSecureString()); + + var act = () => AuthenticationHeaderFactory.Create(credentials, shouldUseBearer: false); + + act.Should().Throw(); + } + + private static void AssertAreEqualUserNameAndPassword( + string expectedUser, + string expectedPassword, string userAndPasswordBase64String) + { string userNameAndPassword = AuthenticationHeaderFactory.BasicAuthEncoding.GetString(Convert.FromBase64String(userAndPasswordBase64String)); @@ -76,5 +193,20 @@ private void AssertAreEqualUserNameAndPassword(string expectedUser, string expec userNameAndPasswordTokens.Should().HaveElementAt(0, expectedUser); userNameAndPasswordTokens.Should().HaveElementAt(1, expectedPassword); } + + private static IUsernameAndPasswordCredentials MockBasicAuthCredentials(string userName, SecureString password) + { + var mock = Substitute.For(); + mock.UserName.Returns(userName); + mock.Password.Returns(password); + return mock; + } + + private static ITokenCredentials MockTokenCredentials(SecureString token) + { + var mock = Substitute.For(); + mock.Token.Returns(token); + return mock; + } } } diff --git a/src/SonarQube.Client.Tests/Models/ConnectionInformationTests.cs b/src/SonarQube.Client.Tests/Models/ConnectionInformationTests.cs index 07df37d347..7a55c077b4 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)); @@ -76,61 +73,73 @@ public void Ctor_SonarCloudUrl_IsProcessedCorrectly(string inputUrl) [DataRow("http://localhost", "user1", "secret", null)] [DataRow("http://sonarcloud.io", null, null, "myorg")] [DataRow("http://sonarcloud.io", "a token", null, "myorg")] - public void Clone_PropertiesAreCopiedCorrectly(string serverUrl, string userName, string password, string orgKey) + public void Clone_PropertiesAreCopiedCorrectly( + string serverUrl, + string userName, + string password, + string orgKey) { var securePwd = InitializeSecureString(password); var org = InitializeOrganization(orgKey); + var credentials = MockBasicAuthCredentials(userName, securePwd); - var testSubject = new ConnectionInformation(new Uri(serverUrl), userName, securePwd) - { - Organization = org - }; + var testSubject = new ConnectionInformation(new Uri(serverUrl), credentials) { Organization = org }; var cloneObj = ((ICloneable)testSubject).Clone(); 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) => // The "ToSecureString" doesn't expect nulls, which we want to use in the tests password?.ToSecureString(); - private static SonarQubeOrganization InitializeOrganization(string orgKey) => - orgKey == null ? null : new SonarQubeOrganization(orgKey, Guid.NewGuid().ToString()); + private static SonarQubeOrganization InitializeOrganization(string orgKey) => orgKey == null ? null : new SonarQubeOrganization(orgKey, Guid.NewGuid().ToString()); 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 = (IUsernameAndPasswordCredentials)item1.Credentials; + var credentials2 = (IUsernameAndPasswordCredentials)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 IUsernameAndPasswordCredentials 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..5045b3bdc5 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..9b8034b51f 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; @@ -71,7 +66,11 @@ public void TestInitialize() ResetService(); } - protected void SetupRequest(string relativePath, string response, HttpStatusCode statusCode = HttpStatusCode.OK, string serverUrl = DefaultBasePath) => + protected void SetupRequest( + string relativePath, + string response, + HttpStatusCode statusCode = HttpStatusCode.OK, + string serverUrl = DefaultBasePath) => MocksHelper.SetupHttpRequest(messageHandler, relativePath, response, statusCode, serverUrl); protected void SetupRequest(string relativePath, HttpResponseMessage response, params MediaTypeHeaderValue[] expectedHeaderValues) => @@ -94,7 +93,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 +119,14 @@ protected internal virtual SonarQubeService CreateTestSubject() { return new SonarQubeService(messageHandler.Object, UserAgent, logger, requestFactorySelector, secondaryIssueHashUpdater.Object, sseStreamFactory.Object); } + + private static IUsernameAndPasswordCredentials 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..ee401ca901 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; @@ -30,28 +28,48 @@ namespace SonarQube.Client.Helpers public static class AuthenticationHeaderFactory { internal const string BasicAuthCredentialSeparator = ":"; + private const string BearerScheme = "Bearer"; + private const string BasicScheme = "Basic"; /// /// Encoding used to create the basic authentication token /// internal static readonly Encoding BasicAuthEncoding = Encoding.UTF8; - public static AuthenticationHeaderValue Create(string userName, SecureString password, AuthenticationType authentication) + public static AuthenticationHeaderValue Create(IConnectionCredentials credentials, bool shouldUseBearer = true) { - if (authentication == AuthenticationType.Basic) + switch (credentials) { - return string.IsNullOrWhiteSpace(userName) - ? null - : new AuthenticationHeaderValue("Basic", GetBasicAuthToken(userName, password)); - // See more info: https://www.visualstudio.com/en-us/integrate/get-started/auth/overview + case ITokenCredentials tokenCredentials: + { + ValidateSecureString(tokenCredentials.Token, nameof(tokenCredentials.Token)); + return CreateAuthenticationHeaderValueForTokenAuth(shouldUseBearer, tokenCredentials); + } + case IUsernameAndPasswordCredentials basicAuthCredentials: + { + ValidateCredentials(basicAuthCredentials); + return CreateAuthenticationHeaderValueForBasicAuth(basicAuthCredentials.UserName, basicAuthCredentials.Password); + } + case INoCredentials: + return null; + default: + Debug.Fail("Unsupported Authentication: " + credentials?.GetType()); + return null; } - else + } + + private static AuthenticationHeaderValue CreateAuthenticationHeaderValueForTokenAuth(bool shouldUseBearer, ITokenCredentials tokenCredentials) + { + if (shouldUseBearer) { - Debug.Fail("Unsupported Authentication: " + authentication); - return null; + return new AuthenticationHeaderValue(BearerScheme, tokenCredentials.Token.ToUnsecureString()); } + return CreateAuthenticationHeaderValueForBasicAuth(tokenCredentials.Token.ToUnsecureString(), new SecureString()); } + private static AuthenticationHeaderValue CreateAuthenticationHeaderValueForBasicAuth(string username, SecureString password) => new(BasicScheme, GetBasicAuthToken(username, password)); + + // See more info: https://www.visualstudio.com/en-us/integrate/get-started/auth/overview internal static string GetBasicAuthToken(string user, SecureString password) { if (!string.IsNullOrEmpty(user) && user.Contains(BasicAuthCredentialSeparator)) @@ -64,5 +82,22 @@ internal static string GetBasicAuthToken(string user, SecureString password) return Convert.ToBase64String(BasicAuthEncoding.GetBytes(string.Join(BasicAuthCredentialSeparator, user, password.ToUnsecureString()))); } + + private static void ValidateCredentials(IUsernameAndPasswordCredentials credentials) + { + if (string.IsNullOrEmpty(credentials.UserName)) + { + throw new ArgumentException(nameof(IUsernameAndPasswordCredentials.UserName)); + } + ValidateSecureString(credentials.Password, nameof(IUsernameAndPasswordCredentials.Password)); + } + + private static void ValidateSecureString(SecureString secureString, string parameterName) + { + if (secureString.IsNullOrEmpty()) + { + throw new ArgumentException(parameterName); + } + } } } 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..f8eed1781a --- /dev/null +++ b/src/SonarQube.Client/Models/IConnectionCredentials.cs @@ -0,0 +1,42 @@ +/* + * 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 IUsernameAndPasswordCredentials : IConnectionCredentials +{ + public string UserName { get; } + public SecureString Password { get; } +} + +public interface ITokenCredentials : IConnectionCredentials +{ + public SecureString Token { 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..d817747fa5 100644 --- a/src/SonarQube.Client/SonarQubeService.cs +++ b/src/SonarQube.Client/SonarQubeService.cs @@ -46,6 +46,7 @@ public class SonarQubeService : ISonarQubeService, IDisposable private readonly ISecondaryIssueHashUpdater secondaryIssueHashUpdater; private readonly ISSEStreamReaderFactory sseStreamReaderFactory; + private const string MinSqVersionSupportingBearer = "10.4"; private HttpClient httpClient; private ServerInfo currentServerInfo; @@ -67,7 +68,10 @@ public SonarQubeService(HttpMessageHandler messageHandler, string userAgent, ILo { } - internal /* for testing */ SonarQubeService(HttpMessageHandler messageHandler, string userAgent, ILogger logger, + internal /* for testing */ SonarQubeService( + HttpMessageHandler messageHandler, + string userAgent, + ILogger logger, IRequestFactorySelector requestFactorySelector, ISecondaryIssueHashUpdater secondaryIssueHashUpdater, ISSEStreamReaderFactory sseStreamReaderFactory) @@ -110,7 +114,8 @@ private Task InvokeCheckedRequestAsync(Cancellat /// Action that configures a type instance that implements TRequest. /// Cancellation token. /// Returns the result of the request invocation. - private Task InvokeCheckedRequestAsync(Action configure, + private Task InvokeCheckedRequestAsync( + Action configure, CancellationToken token) where TRequest : IRequest { @@ -123,13 +128,23 @@ private Task InvokeCheckedRequestAsync(Action. /// - protected virtual async Task InvokeUncheckedRequestAsync(Action configure, CancellationToken token) + private async Task InvokeUncheckedRequestAsync(Action configure, CancellationToken token) + where TRequest : IRequest + { + return await InvokeUncheckedRequestAsync(configure, httpClient, token); + } + + /// + /// Executes the call without checking whether the connection to the server has been established. This should only normally be used directly while connecting. + /// Other uses should call . + /// + protected virtual async Task InvokeUncheckedRequestAsync(Action configure, HttpClient httpClientParam, CancellationToken token) where TRequest : IRequest { var request = requestFactory.Create(currentServerInfo); configure(request); - var result = await request.InvokeAsync(httpClient, token); + var result = await request.InvokeAsync(httpClientParam, token); return result; } @@ -139,18 +154,6 @@ public async Task ConnectAsync(ConnectionInformation connection, CancellationTok logger.Info($"Connecting to '{connection.ServerUri}'."); logger.Debug($"IsConnected is {IsConnected}."); - httpClient = new HttpClient(messageHandler) - { - BaseAddress = connection.ServerUri, - DefaultRequestHeaders = - { - Authorization = AuthenticationHeaderFactory.Create( - connection.UserName, connection.Password, connection.Authentication), - }, - }; - - httpClient.DefaultRequestHeaders.Add("User-Agent", userAgent); - requestFactory = requestFactorySelector.Select(connection.IsSonarCloud, logger); try @@ -158,14 +161,12 @@ public async Task ConnectAsync(ConnectionInformation connection, CancellationTok var serverTypeDescription = connection.IsSonarCloud ? "SonarCloud" : "SonarQube"; logger.Debug($"Getting the version of {serverTypeDescription}..."); - - var versionResponse = await InvokeUncheckedRequestAsync(request => { }, token); - var serverInfo = new ServerInfo(Version.Parse(versionResponse), connection.IsSonarCloud ? ServerType.SonarCloud : ServerType.SonarQube); + var serverInfo = await GetServerInfo(connection, token); logger.Info($"Connected to {serverTypeDescription} '{serverInfo.Version}'."); + httpClient = CreateHttpClient(connection.ServerUri, connection.Credentials, ShouldUseBearer(serverInfo)); logger.Debug($"Validating the credentials..."); - var credentialResponse = await InvokeUncheckedRequestAsync(request => { }, token); if (!credentialResponse) { @@ -173,7 +174,6 @@ public async Task ConnectAsync(ConnectionInformation connection, CancellationTok } logger.Debug($"Credentials accepted."); - currentServerInfo = serverInfo; } catch @@ -215,8 +215,7 @@ await InvokeCheckedRequestAsync> GetAllLanguagesAsync(CancellationToken token) => - await InvokeCheckedRequestAsync(token); + public async Task> GetAllLanguagesAsync(CancellationToken token) => await InvokeCheckedRequestAsync(token); public async Task DownloadStaticFileAsync(string pluginKey, string fileName, CancellationToken token) => await InvokeCheckedRequestAsync( @@ -227,8 +226,7 @@ await InvokeCheckedRequestAsync( }, token); - public async Task> GetAllPluginsAsync(CancellationToken token) => - await InvokeCheckedRequestAsync(token); + public async Task> GetAllPluginsAsync(CancellationToken token) => await InvokeCheckedRequestAsync(token); public async Task> GetAllProjectsAsync(string organizationKey, CancellationToken token) => await InvokeCheckedRequestAsync( @@ -269,7 +267,11 @@ public async Task> GetAllQualityProfilesAsync(str token); } - public async Task GetQualityProfileAsync(string projectKey, string organizationKey, SonarQubeLanguage language, CancellationToken token) + public async Task GetQualityProfileAsync( + string projectKey, + string organizationKey, + SonarQubeLanguage language, + CancellationToken token) { var qualityProfiles = await InvokeCheckedRequestAsync( request => @@ -310,8 +312,11 @@ public async Task GetQualityProfileAsync(string project qualityProfile.IsDefault, updatedDate); } - public async Task GetRoslynExportProfileAsync(string qualityProfileName, - string organizationKey, SonarQubeLanguage language, CancellationToken token) => + public async Task GetRoslynExportProfileAsync( + string qualityProfileName, + string organizationKey, + SonarQubeLanguage language, + CancellationToken token) => await InvokeCheckedRequestAsync( request => { @@ -321,8 +326,11 @@ await InvokeCheckedRequestAsync> GetSuppressedIssuesAsync(string projectKey, string branch, - string[] issueKeys, CancellationToken token) => + public async Task> GetSuppressedIssuesAsync( + string projectKey, + string branch, + string[] issueKeys, + CancellationToken token) => await InvokeCheckedRequestAsync( request => { @@ -333,7 +341,11 @@ await InvokeCheckedRequestAsync( }, token); - public async Task> GetIssuesForComponentAsync(string projectKey, string branch, string componentKey, string ruleId, + public async Task> GetIssuesForComponentAsync( + string projectKey, + string branch, + string componentKey, + string ruleId, CancellationToken token) { return await InvokeCheckedRequestAsync( @@ -347,7 +359,9 @@ public async Task> GetIssuesForComponentAsync(string proje token); } - public async Task> GetNotificationEventsAsync(string projectKey, DateTimeOffset eventsSince, + public async Task> GetNotificationEventsAsync( + string projectKey, + DateTimeOffset eventsSince, CancellationToken token) => await InvokeCheckedRequestAsync( request => @@ -366,17 +380,21 @@ await InvokeCheckedRequestAsync( }, token); - public async Task> SearchFilesByNameAsync(string projectKey, string branch, string fileName, CancellationToken token) + public async Task> SearchFilesByNameAsync( + string projectKey, + string branch, + string fileName, + CancellationToken token) { return await InvokeCheckedRequestAsync( - request => - { - request.ProjectKey = projectKey; - request.BranchName = branch; - request.FileName = fileName; - }, - token - ); + request => + { + request.ProjectKey = projectKey; + request.BranchName = branch; + request.FileName = fileName; + }, + token + ); } public async Task> GetRulesAsync(bool isActive, string qualityProfileKey, CancellationToken token) => @@ -386,7 +404,7 @@ await InvokeCheckedRequestAsync( request.IsActive = isActive; request.QualityProfileKey = qualityProfileKey; }, - token); + token); public async Task GetRuleByKeyAsync(string ruleKey, string qualityProfileKey, CancellationToken token) { @@ -396,7 +414,7 @@ public async Task GetRuleByKeyAsync(string ruleKey, string qualit request.RuleKey = ruleKey; request.QualityProfileKey = qualityProfileKey; }, - token); + token); Debug.Assert(rules.Length <= 1); return rules.FirstOrDefault(); @@ -410,7 +428,8 @@ await InvokeCheckedRequestAsync( }, token); - public async Task> SearchHotspotsAsync(string projectKey, string branch, CancellationToken token) => await InvokeCheckedRequestAsync( + public async Task> SearchHotspotsAsync(string projectKey, string branch, CancellationToken token) => + await InvokeCheckedRequestAsync( request => { request.BranchKey = branch; @@ -418,7 +437,11 @@ public async Task> SearchHotspotsAsync(string proj }, token); - public async Task TransitionIssueAsync(string issueKey, SonarQubeIssueTransition transition, string optionalComment, CancellationToken token) + public async Task TransitionIssueAsync( + string issueKey, + SonarQubeIssueTransition transition, + string optionalComment, + CancellationToken token) { var transitionResult = await InvokeCheckedRequestAsync( request => @@ -554,5 +577,25 @@ public void Dispose() } return organizationKey; } + + private static bool ShouldUseBearer(ServerInfo serverInfo) + { + return serverInfo.ServerType == ServerType.SonarCloud || serverInfo.Version >= Version.Parse(MinSqVersionSupportingBearer); + } + + private async Task GetServerInfo(ConnectionInformation connection, CancellationToken token) + { + var http = CreateHttpClient(connection.ServerUri, new NoCredentials(), shouldUseBearer: true); + var versionResponse = await InvokeUncheckedRequestAsync(request => { }, http, token); + var serverInfo = new ServerInfo(Version.Parse(versionResponse), connection.IsSonarCloud ? ServerType.SonarCloud : ServerType.SonarQube); + return serverInfo; + } + + private HttpClient CreateHttpClient(Uri baseAddress, IConnectionCredentials credentials, bool shouldUseBearer) + { + var client = new HttpClient(messageHandler) { BaseAddress = baseAddress, DefaultRequestHeaders = { Authorization = AuthenticationHeaderFactory.Create(credentials, shouldUseBearer), }, }; + client.DefaultRequestHeaders.Add("User-Agent", userAgent); + return client; + } } }