зеркало из https://github.com/github/VisualStudio.git
Merge branch 'master' into feature/inline-reviews
Conflicts: src/GitHub.App/ViewModels/PullRequestDetailViewModel.cs src/GitHub.VisualStudio/Properties/AssemblyInfo.cs src/GitHub.VisualStudio/source.extension.vsixmanifest src/MsiInstaller/Version.wxi src/common/SolutionInfo.cs
This commit is contained in:
Коммит
3097190685
|
@ -1,5 +1,6 @@
|
|||
<ProjectConfiguration>
|
||||
<Settings>
|
||||
<PreventSigningOfAssembly>True</PreventSigningOfAssembly>
|
||||
<PreviouslyBuiltSuccessfully>True</PreviouslyBuiltSuccessfully>
|
||||
</Settings>
|
||||
</ProjectConfiguration>
|
|
@ -1,5 +1,6 @@
|
|||
<ProjectConfiguration>
|
||||
<Settings>
|
||||
<PreventSigningOfAssembly>True</PreventSigningOfAssembly>
|
||||
<PreviouslyBuiltSuccessfully>True</PreviouslyBuiltSuccessfully>
|
||||
</Settings>
|
||||
</ProjectConfiguration>
|
|
@ -1,5 +1,6 @@
|
|||
<ProjectConfiguration>
|
||||
<Settings>
|
||||
<PreventSigningOfAssembly>True</PreventSigningOfAssembly>
|
||||
<PreviouslyBuiltSuccessfully>True</PreviouslyBuiltSuccessfully>
|
||||
</Settings>
|
||||
</ProjectConfiguration>
|
|
@ -1,5 +1,6 @@
|
|||
<ProjectConfiguration>
|
||||
<Settings>
|
||||
<PreloadReferencedAssemblies>False</PreloadReferencedAssemblies>
|
||||
<PreventSigningOfAssembly>True</PreventSigningOfAssembly>
|
||||
<PreviouslyBuiltSuccessfully>True</PreviouslyBuiltSuccessfully>
|
||||
</Settings>
|
||||
|
|
|
@ -41,7 +41,7 @@ Open the `GitHubVS.sln` solution with Visual Studio 2015.
|
|||
To be able to use the GitHub API, you'll need to:
|
||||
|
||||
- [Register a new developer application](https://github.com/settings/developers) in your profile.
|
||||
- Open [src/GitHub.App/Api/ApiClientConfiguration.cs](src/GitHub.App/Api/ApiClientConfiguration.cs) and fill out the clientId/clientSecret fields for your application.
|
||||
- Open [src/GitHub.Api/ApiClientConfiguration_User.cs](src/GitHub.Api/ApiClientConfiguration_User.cs) and fill out the clientId/clientSecret fields for your application. **Note this has recently changed location, so you may need to re-do this**
|
||||
|
||||
Build using Visual Studio 2015 or:
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
<NuGetPackageImportStamp>
|
||||
</NuGetPackageImportStamp>
|
||||
<TargetFrameworkProfile />
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
|
||||
<OutputPath>bin\$(Configuration)\</OutputPath>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GitHub.Primitives;
|
||||
|
||||
namespace GitHub.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// Holds the configuration for API clients.
|
||||
/// </summary>
|
||||
public static partial class ApiClientConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes static members of the <see cref="ApiClientConfiguration"/> class.
|
||||
/// </summary>
|
||||
static ApiClientConfiguration()
|
||||
{
|
||||
Configure();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the application's OAUTH client ID.
|
||||
/// </summary>
|
||||
public static string ClientId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the application's OAUTH client secret.
|
||||
/// </summary>
|
||||
public static string ClientSecret { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a note that will be stored with the OAUTH token.
|
||||
/// </summary>
|
||||
public static string AuthorizationNote
|
||||
{
|
||||
get { return Info.ApplicationInfo.ApplicationDescription + " on " + GetMachineNameSafe(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the machine fingerprint that will be registered with the OAUTH token, allowing
|
||||
/// multiple authorizations to be created for a single user.
|
||||
/// </summary>
|
||||
public static string MachineFingerprint
|
||||
{
|
||||
get
|
||||
{
|
||||
return GetSha256Hash(Info.ApplicationInfo.ApplicationDescription + ":" + GetMachineIdentifier());
|
||||
}
|
||||
}
|
||||
|
||||
static partial void Configure();
|
||||
|
||||
static string GetMachineIdentifier()
|
||||
{
|
||||
try
|
||||
{
|
||||
// adapted from http://stackoverflow.com/a/1561067
|
||||
var fastedValidNetworkInterface = NetworkInterface.GetAllNetworkInterfaces()
|
||||
.OrderBy(nic => nic.Speed)
|
||||
.Where(nic => nic.OperationalStatus == OperationalStatus.Up)
|
||||
.Select(nic => nic.GetPhysicalAddress().ToString())
|
||||
.FirstOrDefault(address => address.Length > 12);
|
||||
|
||||
return fastedValidNetworkInterface ?? GetMachineNameSafe();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
//log.Info("Could not retrieve MAC address. Fallback to using machine name.", e);
|
||||
return GetMachineNameSafe();
|
||||
}
|
||||
}
|
||||
|
||||
static string GetMachineNameSafe()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Dns.GetHostName();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
//log.Info("Failed to retrieve host name using `DNS.GetHostName`.", e);
|
||||
|
||||
try
|
||||
{
|
||||
return Environment.MachineName;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
//log.Info("Failed to retrieve host name using `Environment.MachineName`.", ex);
|
||||
return "(unknown)";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static string GetSha256Hash(string input)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var sha256 = SHA256.Create())
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = sha256.ComputeHash(bytes);
|
||||
|
||||
return string.Join("", hash.Select(b => b.ToString("x2", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
//log.Error("IMPOSSIBLE! Generating Sha256 hash caused an exception.", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,11 +1,13 @@
|
|||
using System;
|
||||
|
||||
namespace GitHub.Api
|
||||
{
|
||||
public partial class ApiClient : IApiClient
|
||||
static partial class ApiClientConfiguration
|
||||
{
|
||||
const string clientId = "YOUR CLIENT ID HERE";
|
||||
const string clientSecret = "YOUR CLIENT SECRET HERE";
|
||||
|
||||
partial void Configure()
|
||||
static partial void Configure()
|
||||
{
|
||||
ClientId = clientId;
|
||||
ClientSecret = clientSecret;
|
|
@ -11,7 +11,7 @@
|
|||
<AssemblyName>GitHub.Api</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
|
||||
<OutputPath>bin\$(Configuration)\</OutputPath>
|
||||
<TargetFrameworkProfile />
|
||||
</PropertyGroup>
|
||||
|
@ -55,7 +55,17 @@
|
|||
<Reference Include="System.Xml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="ApiClientConfiguration.cs"/>
|
||||
<Compile Include="..\..\script\src\ApiClientConfiguration_User.cs" Condition="$(Buildtype) == 'Internal'">
|
||||
<Link>ApiClientConfiguration_User.cs</Link>
|
||||
</Compile>
|
||||
<Compile Include="ApiClientConfiguration_User.cs" Condition="$(Buildtype) != 'Internal'"/>
|
||||
<Compile Include="ILoginCache.cs" />
|
||||
<Compile Include="ILoginManager.cs" />
|
||||
<Compile Include="ITwoFactorChallengeHandler.cs" />
|
||||
<Compile Include="LoginManager.cs" />
|
||||
<Compile Include="SimpleCredentialStore.cs" />
|
||||
<Compile Include="WindowsLoginCache.cs" />
|
||||
<None Include="..\..\script\Key.snk" Condition="$(Buildtype) == 'Internal'">
|
||||
<Link>Key.snk</Link>
|
||||
</None>
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Primitives;
|
||||
|
||||
namespace GitHub.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores login details.
|
||||
/// </summary>
|
||||
public interface ILoginCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the login details for the specified host address.
|
||||
/// </summary>
|
||||
/// <param name="hostAddress">The host address.</param>
|
||||
/// <returns>
|
||||
/// A task returning a tuple containing the retrieved username and password.
|
||||
/// </returns>
|
||||
Task<Tuple<string, string>> GetLogin(HostAddress hostAddress);
|
||||
|
||||
/// <summary>
|
||||
/// Saves the login details for the specified host address.
|
||||
/// </summary>
|
||||
/// <param name="userName">The username.</param>
|
||||
/// <param name="password">The password.</param>
|
||||
/// <param name="hostAddress">The host address.</param>
|
||||
/// <returns>A task tracking the operation.</returns>
|
||||
Task SaveLogin(string userName, string password, HostAddress hostAddress);
|
||||
|
||||
/// <summary>
|
||||
/// Removes the login details for the specified host address.
|
||||
/// </summary>
|
||||
/// <param name="hostAddress"></param>
|
||||
/// <returns>A task tracking the operation.</returns>
|
||||
Task EraseLogin(HostAddress hostAddress);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Primitives;
|
||||
using GitHub.VisualStudio;
|
||||
using Octokit;
|
||||
|
||||
namespace GitHub.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides services for logging into a GitHub server.
|
||||
/// </summary>
|
||||
[Guid(Guids.LoginManagerId)]
|
||||
public interface ILoginManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to log into a GitHub server.
|
||||
/// </summary>
|
||||
/// <param name="hostAddress">The address of the server.</param>
|
||||
/// <param name="client">An octokit client configured to access the server.</param>
|
||||
/// <param name="userName">The username.</param>
|
||||
/// <param name="password">The password.</param>
|
||||
/// <returns>The logged in user.</returns>
|
||||
/// <exception cref="AuthorizationException">
|
||||
/// The login authorization failed.
|
||||
/// </exception>
|
||||
Task<User> Login(HostAddress hostAddress, IGitHubClient client, string userName, string password);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to log into a GitHub server using existing credentials.
|
||||
/// </summary>
|
||||
/// <param name="hostAddress">The address of the server.</param>
|
||||
/// <param name="client">An octokit client configured to access the server.</param>
|
||||
/// <returns>The logged in user.</returns>
|
||||
/// <exception cref="AuthorizationException">
|
||||
/// The login authorization failed.
|
||||
/// </exception>
|
||||
Task<User> LoginFromCache(HostAddress hostAddress, IGitHubClient client);
|
||||
|
||||
/// <summary>
|
||||
/// Logs out of GitHub server.
|
||||
/// </summary>
|
||||
/// <param name="hostAddress">The address of the server.</param>
|
||||
Task Logout(HostAddress hostAddress, IGitHubClient client);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Octokit;
|
||||
|
||||
namespace GitHub.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for handling Two Factor Authentication challenges in a
|
||||
/// <see cref="LoginManager"/> operation.
|
||||
/// </summary>
|
||||
public interface ITwoFactorChallengeHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Called when the GitHub API responds to a login request with a 2FA challenge.
|
||||
/// </summary>
|
||||
/// <param name="exception">The 2FA exception that initiated the challenge.</param>
|
||||
/// <returns>A task returning a <see cref="TwoFactorChallengeResult"/>.</returns>
|
||||
Task<TwoFactorChallengeResult> HandleTwoFactorException(TwoFactorAuthorizationException exception);
|
||||
|
||||
/// <summary>
|
||||
/// Called when an error occurs sending the 2FA challenge response.
|
||||
/// </summary>
|
||||
/// <param name="e">The exception that occurred.</param>
|
||||
/// <remarks>
|
||||
/// This method is called when, on sending the challenge response returned by
|
||||
/// <see cref="HandleTwoFactorException(TwoFactorAuthorizationException)"/>, an exception of
|
||||
/// a type other than <see cref="TwoFactorAuthorizationException"/> is thrown. This
|
||||
/// indicates that the login attempt is over and any 2FA dialog being shown should close.
|
||||
/// </remarks>
|
||||
Task ChallengeFailed(Exception e);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,262 @@
|
|||
using System;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Extensions;
|
||||
using GitHub.Primitives;
|
||||
using Octokit;
|
||||
|
||||
namespace GitHub.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides services for logging into a GitHub server.
|
||||
/// </summary>
|
||||
public class LoginManager : ILoginManager
|
||||
{
|
||||
readonly string[] scopes = { "user", "repo", "gist", "write:public_key" };
|
||||
readonly ILoginCache loginCache;
|
||||
readonly ITwoFactorChallengeHandler twoFactorChallengeHandler;
|
||||
readonly string clientId;
|
||||
readonly string clientSecret;
|
||||
readonly string authorizationNote;
|
||||
readonly string fingerprint;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LoginManager"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loginCache">The cache in which to store login details.</param>
|
||||
/// <param name="twoFactorChallengeHandler">The handler for 2FA challenges.</param>
|
||||
/// <param name="clientId">The application's client API ID.</param>
|
||||
/// <param name="clientSecret">The application's client API secret.</param>
|
||||
/// <param name="authorizationNote">An note to store with the authorization.</param>
|
||||
/// <param name="fingerprint">The machine fingerprint.</param>
|
||||
public LoginManager(
|
||||
ILoginCache loginCache,
|
||||
ITwoFactorChallengeHandler twoFactorChallengeHandler,
|
||||
string clientId,
|
||||
string clientSecret,
|
||||
string authorizationNote = null,
|
||||
string fingerprint = null)
|
||||
{
|
||||
Guard.ArgumentNotNull(loginCache, nameof(loginCache));
|
||||
Guard.ArgumentNotNull(twoFactorChallengeHandler, nameof(twoFactorChallengeHandler));
|
||||
Guard.ArgumentNotEmptyString(clientId, nameof(clientId));
|
||||
Guard.ArgumentNotEmptyString(clientSecret, nameof(clientSecret));
|
||||
|
||||
this.loginCache = loginCache;
|
||||
this.twoFactorChallengeHandler = twoFactorChallengeHandler;
|
||||
this.clientId = clientId;
|
||||
this.clientSecret = clientSecret;
|
||||
this.authorizationNote = authorizationNote;
|
||||
this.fingerprint = fingerprint;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<User> Login(
|
||||
HostAddress hostAddress,
|
||||
IGitHubClient client,
|
||||
string userName,
|
||||
string password)
|
||||
{
|
||||
Guard.ArgumentNotNull(hostAddress, nameof(hostAddress));
|
||||
Guard.ArgumentNotNull(client, nameof(client));
|
||||
Guard.ArgumentNotEmptyString(userName, nameof(userName));
|
||||
Guard.ArgumentNotEmptyString(password, nameof(password));
|
||||
|
||||
// Start by saving the username and password, these will be used by the `IGitHubClient`
|
||||
// until an authorization token has been created and acquired:
|
||||
await loginCache.SaveLogin(userName, password, hostAddress).ConfigureAwait(false);
|
||||
|
||||
var newAuth = new NewAuthorization
|
||||
{
|
||||
Scopes = scopes,
|
||||
Note = authorizationNote,
|
||||
Fingerprint = fingerprint,
|
||||
};
|
||||
|
||||
ApplicationAuthorization auth = null;
|
||||
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
auth = await CreateAndDeleteExistingApplicationAuthorization(client, newAuth, null)
|
||||
.ConfigureAwait(false);
|
||||
EnsureNonNullAuthorization(auth);
|
||||
}
|
||||
catch (TwoFactorAuthorizationException e)
|
||||
{
|
||||
auth = await HandleTwoFactorAuthorization(hostAddress, client, newAuth, e)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Some enterpise instances don't support OAUTH, so fall back to using the
|
||||
// supplied password - on intances that don't support OAUTH the user should
|
||||
// be using a personal access token as the password.
|
||||
if (EnterpriseWorkaround(hostAddress, e))
|
||||
{
|
||||
auth = new ApplicationAuthorization(password);
|
||||
}
|
||||
else
|
||||
{
|
||||
await loginCache.EraseLogin(hostAddress).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
} while (auth == null);
|
||||
|
||||
await loginCache.SaveLogin(userName, auth.Token, hostAddress).ConfigureAwait(false);
|
||||
|
||||
var retry = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await client.User.Current().ConfigureAwait(false);
|
||||
}
|
||||
catch (AuthorizationException)
|
||||
{
|
||||
if (retry++ == 3) throw;
|
||||
}
|
||||
|
||||
// It seems that attempting to use a token immediately sometimes fails, retry a few
|
||||
// times with a delay of of 1s to allow the token to propagate.
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<User> LoginFromCache(HostAddress hostAddress, IGitHubClient client)
|
||||
{
|
||||
Guard.ArgumentNotNull(hostAddress, nameof(hostAddress));
|
||||
Guard.ArgumentNotNull(client, nameof(client));
|
||||
|
||||
return client.User.Current();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task Logout(HostAddress hostAddress, IGitHubClient client)
|
||||
{
|
||||
Guard.ArgumentNotNull(hostAddress, nameof(hostAddress));
|
||||
Guard.ArgumentNotNull(client, nameof(client));
|
||||
|
||||
await loginCache.EraseLogin(hostAddress);
|
||||
}
|
||||
|
||||
async Task<ApplicationAuthorization> CreateAndDeleteExistingApplicationAuthorization(
|
||||
IGitHubClient client,
|
||||
NewAuthorization newAuth,
|
||||
string twoFactorAuthenticationCode)
|
||||
{
|
||||
ApplicationAuthorization result;
|
||||
var retry = 0;
|
||||
|
||||
do
|
||||
{
|
||||
if (twoFactorAuthenticationCode == null)
|
||||
{
|
||||
result = await client.Authorization.GetOrCreateApplicationAuthentication(
|
||||
clientId,
|
||||
clientSecret,
|
||||
newAuth).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await client.Authorization.GetOrCreateApplicationAuthentication(
|
||||
clientId,
|
||||
clientSecret,
|
||||
newAuth,
|
||||
twoFactorAuthenticationCode).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result.Token == string.Empty)
|
||||
{
|
||||
if (twoFactorAuthenticationCode == null)
|
||||
{
|
||||
await client.Authorization.Delete(result.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
await client.Authorization.Delete(result.Id, twoFactorAuthenticationCode);
|
||||
}
|
||||
}
|
||||
} while (result.Token == string.Empty && retry++ == 0);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async Task<ApplicationAuthorization> HandleTwoFactorAuthorization(
|
||||
HostAddress hostAddress,
|
||||
IGitHubClient client,
|
||||
NewAuthorization newAuth,
|
||||
TwoFactorAuthorizationException exception)
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
var challengeResult = await twoFactorChallengeHandler.HandleTwoFactorException(exception);
|
||||
|
||||
if (challengeResult == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"ITwoFactorChallengeHandler.HandleTwoFactorException returned null.");
|
||||
}
|
||||
|
||||
if (!challengeResult.ResendCodeRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auth = await CreateAndDeleteExistingApplicationAuthorization(
|
||||
client,
|
||||
newAuth,
|
||||
challengeResult.AuthenticationCode).ConfigureAwait(false);
|
||||
return EnsureNonNullAuthorization(auth);
|
||||
}
|
||||
catch (TwoFactorAuthorizationException e)
|
||||
{
|
||||
exception = e;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
await twoFactorChallengeHandler.ChallengeFailed(e);
|
||||
await loginCache.EraseLogin(hostAddress).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ApplicationAuthorization EnsureNonNullAuthorization(ApplicationAuthorization auth)
|
||||
{
|
||||
// If a mock IGitHubClient is not set up correctly, it can return null from
|
||||
// IGutHubClient.Authorization.Create - this will cause an infinite loop in Login()
|
||||
// so prevent that.
|
||||
if (auth == null)
|
||||
{
|
||||
throw new InvalidOperationException("IGutHubClient.Authorization.Create returned null.");
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
bool EnterpriseWorkaround(HostAddress hostAddress, Exception e)
|
||||
{
|
||||
// Older Enterprise hosts either don't have the API end-point to PUT an authorization, or they
|
||||
// return 422 because they haven't white-listed our client ID. In that case, we just ignore
|
||||
// the failure, using basic authentication (with username and password) instead of trying
|
||||
// to get an authorization token.
|
||||
// Since enterprise 2.1 and https://github.com/github/github/pull/36669 the API returns 403
|
||||
// instead of 404 to signal that it's not allowed. In the name of backwards compatibility we
|
||||
// test for both 404 (NotFoundException) and 403 (ForbiddenException) here.
|
||||
var apiException = e as ApiException;
|
||||
return !hostAddress.IsGitHubDotCom() &&
|
||||
(e is NotFoundException ||
|
||||
e is ForbiddenException ||
|
||||
apiException?.StatusCode == (HttpStatusCode)422);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@ using GitHub.Primitives;
|
|||
using GitHub.Services;
|
||||
using Octokit;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GitHub.Api
|
||||
{
|
||||
|
@ -27,12 +28,13 @@ namespace GitHub.Api
|
|||
lazyWikiProbe = wikiProbe;
|
||||
}
|
||||
|
||||
public ISimpleApiClient Create(UriString repositoryUrl)
|
||||
public Task<ISimpleApiClient> Create(UriString repositoryUrl)
|
||||
{
|
||||
var hostAddress = HostAddress.Create(repositoryUrl);
|
||||
return cache.GetOrAdd(repositoryUrl, new SimpleApiClient(repositoryUrl,
|
||||
var result = cache.GetOrAdd(repositoryUrl, new SimpleApiClient(repositoryUrl,
|
||||
new GitHubClient(productHeader, new SimpleCredentialStore(hostAddress), hostAddress.ApiUri),
|
||||
lazyEnterpriseProbe, lazyWikiProbe));
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public void ClearFromCache(ISimpleApiClient client)
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
using System;
|
||||
using System.ComponentModel.Composition;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Authentication.CredentialManagement;
|
||||
using GitHub.Extensions;
|
||||
using GitHub.Primitives;
|
||||
|
||||
namespace GitHub.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// A login cache that stores logins in the windows credential cache.
|
||||
/// </summary>
|
||||
[Export(typeof(ILoginCache))]
|
||||
[PartCreationPolicy(CreationPolicy.Shared)]
|
||||
public class WindowsLoginCache : ILoginCache
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public Task<Tuple<string, string>> GetLogin(HostAddress hostAddress)
|
||||
{
|
||||
Guard.ArgumentNotNull(hostAddress, nameof(hostAddress));
|
||||
|
||||
var keyHost = GetKeyHost(hostAddress.CredentialCacheKeyHost);
|
||||
|
||||
using (var credential = new Credential())
|
||||
{
|
||||
credential.Target = keyHost;
|
||||
credential.Type = CredentialType.Generic;
|
||||
if (credential.Load())
|
||||
return Task.FromResult(Tuple.Create(credential.Username, credential.Password));
|
||||
}
|
||||
|
||||
return Task.FromResult(Tuple.Create<string, string>(null, null));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task SaveLogin(string userName, string password, HostAddress hostAddress)
|
||||
{
|
||||
Guard.ArgumentNotEmptyString(userName, nameof(userName));
|
||||
Guard.ArgumentNotEmptyString(password, nameof(password));
|
||||
Guard.ArgumentNotNull(hostAddress, nameof(hostAddress));
|
||||
|
||||
var keyHost = GetKeyHost(hostAddress.CredentialCacheKeyHost);
|
||||
|
||||
using (var credential = new Credential(userName, password, keyHost))
|
||||
{
|
||||
credential.Save();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task EraseLogin(HostAddress hostAddress)
|
||||
{
|
||||
Guard.ArgumentNotNull(hostAddress, nameof(hostAddress));
|
||||
|
||||
var keyHost = GetKeyHost(hostAddress.CredentialCacheKeyHost);
|
||||
|
||||
using (var credential = new Credential())
|
||||
{
|
||||
credential.Target = keyHost;
|
||||
credential.Type = CredentialType.Generic;
|
||||
credential.Delete();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
static string GetKeyHost(string key)
|
||||
{
|
||||
key = FormatKey(key);
|
||||
if (key.StartsWith("git:", StringComparison.Ordinal))
|
||||
key = key.Substring("git:".Length);
|
||||
if (!key.EndsWith("/", StringComparison.Ordinal))
|
||||
key += '/';
|
||||
return key;
|
||||
}
|
||||
|
||||
static string FormatKey(string key)
|
||||
{
|
||||
if (key.StartsWith("login:", StringComparison.Ordinal))
|
||||
key = key.Substring("login:".Length);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -41,15 +41,18 @@ namespace GitHub.Api
|
|||
string ClientId { get; set; }
|
||||
string ClientSecret { get; set; }
|
||||
|
||||
public ApiClient(HostAddress hostAddress, IObservableGitHubClient gitHubClient)
|
||||
public ApiClient([AllowNull] HostAddress hostAddress, [AllowNull] IObservableGitHubClient gitHubClient)
|
||||
{
|
||||
Configure();
|
||||
ClientId = ApiClientConfiguration.ClientId;
|
||||
ClientSecret = ApiClientConfiguration.ClientSecret;
|
||||
HostAddress = hostAddress;
|
||||
this.gitHubClient = gitHubClient;
|
||||
}
|
||||
|
||||
partial void Configure();
|
||||
|
||||
public IGitHubClient GitHubClient => new GitHubClient(gitHubClient.Connection);
|
||||
|
||||
public IObservable<Repository> CreateRepository(NewRepository repository, string login, bool isUser)
|
||||
{
|
||||
Guard.ArgumentNotEmptyString(login, nameof(login));
|
||||
|
|
|
@ -5,12 +5,16 @@ using GitHub.ViewModels;
|
|||
using Octokit;
|
||||
using ReactiveUI;
|
||||
using NullGuard;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Api;
|
||||
using GitHub.Helpers;
|
||||
|
||||
namespace GitHub.Authentication
|
||||
{
|
||||
[Export(typeof(ITwoFactorChallengeHandler))]
|
||||
[Export(typeof(IDelegatingTwoFactorChallengeHandler))]
|
||||
[PartCreationPolicy(CreationPolicy.Shared)]
|
||||
public class TwoFactorChallengeHandler : ReactiveObject, ITwoFactorChallengeHandler
|
||||
public class TwoFactorChallengeHandler : ReactiveObject, IDelegatingTwoFactorChallengeHandler
|
||||
{
|
||||
ITwoFactorDialogViewModel twoFactorDialog;
|
||||
[AllowNull]
|
||||
|
@ -26,15 +30,27 @@ namespace GitHub.Authentication
|
|||
CurrentViewModel = vm;
|
||||
}
|
||||
|
||||
public IObservable<TwoFactorChallengeResult> HandleTwoFactorException(TwoFactorAuthorizationException exception)
|
||||
public async Task<TwoFactorChallengeResult> HandleTwoFactorException(TwoFactorAuthorizationException exception)
|
||||
{
|
||||
await ThreadingHelper.SwitchToMainThreadAsync();
|
||||
|
||||
var userError = new TwoFactorRequiredUserError(exception);
|
||||
return twoFactorDialog.Show(userError)
|
||||
.ObserveOn(RxApp.MainThreadScheduler)
|
||||
.SelectMany(x =>
|
||||
x == RecoveryOptionResult.RetryOperation
|
||||
? Observable.Return(userError.ChallengeResult)
|
||||
: Observable.Throw<TwoFactorChallengeResult>(exception));
|
||||
var result = await twoFactorDialog.Show(userError);
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ChallengeFailed(Exception exception)
|
||||
{
|
||||
await ThreadingHelper.SwitchToMainThreadAsync();
|
||||
await twoFactorDialog.Cancel.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -18,14 +18,6 @@ namespace GitHub.Authentication
|
|||
|
||||
public TwoFactorType TwoFactorType { get; private set; }
|
||||
|
||||
[AllowNull]
|
||||
public TwoFactorChallengeResult ChallengeResult
|
||||
{
|
||||
[return: AllowNull]
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
public IObservable<RecoveryOptionResult> Throw()
|
||||
{
|
||||
return Throw(this);
|
||||
|
|
|
@ -8,6 +8,8 @@ using Octokit;
|
|||
using Octokit.Reactive;
|
||||
using ApiClient = GitHub.Api.ApiClient;
|
||||
using GitHub.Infrastructure;
|
||||
using System.Threading.Tasks;
|
||||
using ILoginCache = GitHub.Caches.ILoginCache;
|
||||
|
||||
namespace GitHub.Factories
|
||||
{
|
||||
|
@ -25,13 +27,19 @@ namespace GitHub.Factories
|
|||
config.Configure();
|
||||
}
|
||||
|
||||
public IApiClient Create(HostAddress hostAddress)
|
||||
public Task<IGitHubClient> CreateGitHubClient(HostAddress hostAddress)
|
||||
{
|
||||
var apiBaseUri = hostAddress.ApiUri;
|
||||
return Task.FromResult<IGitHubClient>(new GitHubClient(
|
||||
productHeader,
|
||||
new GitHubCredentialStore(hostAddress, LoginCache),
|
||||
hostAddress.ApiUri));
|
||||
}
|
||||
|
||||
public async Task<IApiClient> Create(HostAddress hostAddress)
|
||||
{
|
||||
return new ApiClient(
|
||||
hostAddress,
|
||||
new ObservableGitHubClient(new GitHubClient(productHeader, new GitHubCredentialStore(hostAddress, LoginCache), apiBaseUri)));
|
||||
new ObservableGitHubClient(await CreateGitHubClient(hostAddress)));
|
||||
}
|
||||
|
||||
protected ILoginCache LoginCache { get; private set; }
|
||||
|
|
|
@ -6,6 +6,9 @@ using GitHub.Models;
|
|||
using GitHub.Primitives;
|
||||
using GitHub.Services;
|
||||
using System.Reactive.Disposables;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Api;
|
||||
using ILoginCache = GitHub.Caches.ILoginCache;
|
||||
|
||||
namespace GitHub.Factories
|
||||
{
|
||||
|
@ -15,9 +18,9 @@ namespace GitHub.Factories
|
|||
{
|
||||
readonly IApiClientFactory apiClientFactory;
|
||||
readonly IHostCacheFactory hostCacheFactory;
|
||||
readonly ILoginManager loginManager;
|
||||
readonly ILoginCache loginCache;
|
||||
readonly IAvatarProvider avatarProvider;
|
||||
readonly ITwoFactorChallengeHandler twoFactorChallengeHandler;
|
||||
readonly CompositeDisposable hosts = new CompositeDisposable();
|
||||
readonly IUsageTracker usage;
|
||||
|
||||
|
@ -25,25 +28,25 @@ namespace GitHub.Factories
|
|||
public RepositoryHostFactory(
|
||||
IApiClientFactory apiClientFactory,
|
||||
IHostCacheFactory hostCacheFactory,
|
||||
ILoginManager loginManager,
|
||||
ILoginCache loginCache,
|
||||
IAvatarProvider avatarProvider,
|
||||
ITwoFactorChallengeHandler twoFactorChallengeHandler,
|
||||
IUsageTracker usage)
|
||||
{
|
||||
this.apiClientFactory = apiClientFactory;
|
||||
this.hostCacheFactory = hostCacheFactory;
|
||||
this.loginManager = loginManager;
|
||||
this.loginCache = loginCache;
|
||||
this.avatarProvider = avatarProvider;
|
||||
this.twoFactorChallengeHandler = twoFactorChallengeHandler;
|
||||
this.usage = usage;
|
||||
}
|
||||
|
||||
public IRepositoryHost Create(HostAddress hostAddress)
|
||||
public async Task<IRepositoryHost> Create(HostAddress hostAddress)
|
||||
{
|
||||
var apiClient = apiClientFactory.Create(hostAddress);
|
||||
var apiClient = await apiClientFactory.Create(hostAddress);
|
||||
var hostCache = hostCacheFactory.Create(hostAddress);
|
||||
var modelService = new ModelService(apiClient, hostCache, avatarProvider);
|
||||
var host = new RepositoryHost(apiClient, modelService, loginCache, twoFactorChallengeHandler, usage);
|
||||
var host = new RepositoryHost(apiClient, modelService, loginManager, loginCache, usage);
|
||||
hosts.Add(host);
|
||||
return host;
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
<RunCodeAnalysis>true</RunCodeAnalysis>
|
||||
<CodeAnalysisRuleSet>..\common\GitHubVS.ruleset</CodeAnalysisRuleSet>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
|
||||
<OutputPath>bin\$(Configuration)\</OutputPath>
|
||||
<TargetFrameworkProfile />
|
||||
</PropertyGroup>
|
||||
|
@ -177,10 +177,6 @@
|
|||
<Compile Include="..\common\SolutionInfo.cs">
|
||||
<Link>Properties\SolutionInfo.cs</Link>
|
||||
</Compile>
|
||||
<Compile Include="..\..\script\src\ApiClientConfiguration.cs" Condition="$(Buildtype) == 'Internal'">
|
||||
<Link>Api\ApiClientConfiguration.cs</Link>
|
||||
</Compile>
|
||||
<Compile Include="Api\ApiClientConfiguration.cs" Condition="$(Buildtype) != 'Internal'" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="AkavacheSqliteLinkerOverride.cs" />
|
||||
|
@ -286,6 +282,10 @@
|
|||
<Project>{41a47c5b-c606-45b4-b83c-22b9239e4da0}</Project>
|
||||
<Name>CredentialManagement</Name>
|
||||
</ProjectReference>
|
||||
<ProjectReference Include="..\GitHub.Api\GitHub.Api.csproj">
|
||||
<Project>{B389ADAF-62CC-486E-85B4-2D8B078DF763}</Project>
|
||||
<Name>GitHub.Api</Name>
|
||||
</ProjectReference>
|
||||
<ProjectReference Include="..\GitHub.Exports.Reactive\GitHub.Exports.Reactive.csproj">
|
||||
<Project>{e4ed0537-d1d9-44b6-9212-3096d7c3f7a1}</Project>
|
||||
<Name>GitHub.Exports.Reactive</Name>
|
||||
|
|
|
@ -18,6 +18,8 @@ using System.Linq;
|
|||
using System.Reactive.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.Extensions;
|
||||
using ILoginCache = GitHub.Caches.ILoginCache;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GitHub.Models
|
||||
{
|
||||
|
@ -27,31 +29,29 @@ namespace GitHub.Models
|
|||
static readonly Logger log = LogManager.GetCurrentClassLogger();
|
||||
static readonly UserAndScopes unverifiedUser = new UserAndScopes(null, null);
|
||||
|
||||
readonly ITwoFactorChallengeHandler twoFactorChallengeHandler;
|
||||
readonly ILoginManager loginManager;
|
||||
readonly HostAddress hostAddress;
|
||||
readonly ILoginCache loginCache;
|
||||
readonly IUsageTracker usage;
|
||||
|
||||
bool isLoggedIn;
|
||||
readonly bool isEnterprise;
|
||||
|
||||
public RepositoryHost(
|
||||
IApiClient apiClient,
|
||||
IModelService modelService,
|
||||
ILoginManager loginManager,
|
||||
ILoginCache loginCache,
|
||||
ITwoFactorChallengeHandler twoFactorChallengeHandler,
|
||||
IUsageTracker usage)
|
||||
{
|
||||
ApiClient = apiClient;
|
||||
ModelService = modelService;
|
||||
this.loginManager = loginManager;
|
||||
this.loginCache = loginCache;
|
||||
this.twoFactorChallengeHandler = twoFactorChallengeHandler;
|
||||
this.usage = usage;
|
||||
|
||||
Debug.Assert(apiClient.HostAddress != null, "HostAddress of an api client shouldn't be null");
|
||||
Address = apiClient.HostAddress;
|
||||
hostAddress = apiClient.HostAddress;
|
||||
isEnterprise = !hostAddress.IsGitHubDotCom();
|
||||
Title = apiClient.HostAddress.Title;
|
||||
}
|
||||
|
||||
|
@ -72,18 +72,32 @@ namespace GitHub.Models
|
|||
[SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope")]
|
||||
public IObservable<AuthenticationResult> LogInFromCache()
|
||||
{
|
||||
return GetUserFromApi()
|
||||
.ObserveOn(RxApp.MainThreadScheduler)
|
||||
.Catch<UserAndScopes, Exception>(ex =>
|
||||
Func<Task<AuthenticationResult>> f = async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ex is AuthorizationException)
|
||||
var user = await loginManager.LoginFromCache(Address, ApiClient.GitHubClient);
|
||||
var accountCacheItem = new AccountCacheItem(user);
|
||||
usage.IncrementLoginCount().Forget();
|
||||
await ModelService.InsertUser(accountCacheItem);
|
||||
|
||||
if (user != unverifiedUser.User)
|
||||
{
|
||||
log.Warn("Got an authorization exception", ex);
|
||||
IsLoggedIn = true;
|
||||
return AuthenticationResult.Success;
|
||||
}
|
||||
return Observable.Return<UserAndScopes>(null);
|
||||
})
|
||||
.SelectMany(LoginWithApiUser)
|
||||
.PublishAsync();
|
||||
else
|
||||
{
|
||||
return AuthenticationResult.VerificationFailure;
|
||||
}
|
||||
}
|
||||
catch (AuthorizationException)
|
||||
{
|
||||
return AuthenticationResult.CredentialFailure;
|
||||
}
|
||||
};
|
||||
|
||||
return f().ToObservable();
|
||||
}
|
||||
|
||||
public IObservable<AuthenticationResult> LogIn(string usernameOrEmail, string password)
|
||||
|
@ -91,114 +105,23 @@ namespace GitHub.Models
|
|||
Guard.ArgumentNotEmptyString(usernameOrEmail, nameof(usernameOrEmail));
|
||||
Guard.ArgumentNotEmptyString(password, nameof(password));
|
||||
|
||||
// If we need to retry on fallback, we'll store the 2FA token
|
||||
// from the first request to re-use:
|
||||
string authenticationCode = null;
|
||||
|
||||
// We need to intercept the 2FA handler to get the token:
|
||||
var interceptingTwoFactorChallengeHandler =
|
||||
new Func<TwoFactorAuthorizationException, IObservable<TwoFactorChallengeResult>>(ex =>
|
||||
twoFactorChallengeHandler.HandleTwoFactorException(ex)
|
||||
.Do(twoFactorChallengeResult =>
|
||||
authenticationCode = twoFactorChallengeResult.AuthenticationCode));
|
||||
|
||||
// Keep the function to save the authorization token here because it's used
|
||||
// in multiple places in the chain below:
|
||||
var saveAuthorizationToken = new Func<ApplicationAuthorization, IObservable<Unit>>(authorization =>
|
||||
return Observable.Defer(async () =>
|
||||
{
|
||||
var token = authorization?.Token;
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return Observable.Return(Unit.Default);
|
||||
var user = await loginManager.Login(Address, ApiClient.GitHubClient, usernameOrEmail, password);
|
||||
var accountCacheItem = new AccountCacheItem(user);
|
||||
usage.IncrementLoginCount().Forget();
|
||||
await ModelService.InsertUser(accountCacheItem);
|
||||
|
||||
return loginCache.SaveLogin(usernameOrEmail, token, Address)
|
||||
.ObserveOn(RxApp.MainThreadScheduler);
|
||||
if (user != unverifiedUser.User)
|
||||
{
|
||||
IsLoggedIn = true;
|
||||
return Observable.Return(AuthenticationResult.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Observable.Return(AuthenticationResult.VerificationFailure);
|
||||
}
|
||||
});
|
||||
|
||||
// Start be saving the username and password, as they will be used for older versions of Enterprise
|
||||
// that don't support authorization tokens, and for the API client to use until an authorization
|
||||
// token has been created and acquired:
|
||||
return loginCache.SaveLogin(usernameOrEmail, password, Address)
|
||||
.ObserveOn(RxApp.MainThreadScheduler)
|
||||
// Try to get an authorization token, save it, then get the user to log in:
|
||||
.SelectMany(fingerprint => ApiClient.GetOrCreateApplicationAuthenticationCode(interceptingTwoFactorChallengeHandler))
|
||||
.SelectMany(saveAuthorizationToken)
|
||||
.SelectMany(_ => GetUserFromApi())
|
||||
.Catch<UserAndScopes, ApiException>(firstTryEx =>
|
||||
{
|
||||
var exception = firstTryEx as AuthorizationException;
|
||||
if (isEnterprise
|
||||
&& exception != null
|
||||
&& exception.Message == "Bad credentials")
|
||||
{
|
||||
return Observable.Throw<UserAndScopes>(exception);
|
||||
}
|
||||
|
||||
// If the Enterprise host doesn't support the write:public_key scope, it'll return a 422.
|
||||
// EXCEPT, there's a bug where it doesn't, and instead creates a bad token, and in
|
||||
// that case we'd get a 401 here from the GetUser invocation. So to be safe (and consistent
|
||||
// with the Mac app), we'll just retry after any API error for Enterprise hosts:
|
||||
if (isEnterprise && !(firstTryEx is TwoFactorChallengeFailedException))
|
||||
{
|
||||
// Because we potentially have a bad authorization token due to the Enterprise bug,
|
||||
// we need to reset to using username and password authentication:
|
||||
return loginCache.SaveLogin(usernameOrEmail, password, Address)
|
||||
.ObserveOn(RxApp.MainThreadScheduler)
|
||||
.SelectMany(_ =>
|
||||
{
|
||||
// Retry with the old scopes. If we have a stashed 2FA token, we use it:
|
||||
if (authenticationCode != null)
|
||||
{
|
||||
return ApiClient.GetOrCreateApplicationAuthenticationCode(
|
||||
interceptingTwoFactorChallengeHandler,
|
||||
authenticationCode,
|
||||
useOldScopes: true,
|
||||
useFingerprint: false);
|
||||
}
|
||||
|
||||
// Otherwise, we use the default handler:
|
||||
return ApiClient.GetOrCreateApplicationAuthenticationCode(
|
||||
interceptingTwoFactorChallengeHandler,
|
||||
useOldScopes: true,
|
||||
useFingerprint: false);
|
||||
})
|
||||
// Then save the authorization token (if there is one) and get the user:
|
||||
.SelectMany(saveAuthorizationToken)
|
||||
.SelectMany(_ => GetUserFromApi());
|
||||
}
|
||||
|
||||
return Observable.Throw<UserAndScopes>(firstTryEx);
|
||||
})
|
||||
.Catch<UserAndScopes, ApiException>(retryEx =>
|
||||
{
|
||||
// Older Enterprise hosts either don't have the API end-point to PUT an authorization, or they
|
||||
// return 422 because they haven't white-listed our client ID. In that case, we just ignore
|
||||
// the failure, using basic authentication (with username and password) instead of trying
|
||||
// to get an authorization token.
|
||||
// Since enterprise 2.1 and https://github.com/github/github/pull/36669 the API returns 403
|
||||
// instead of 404 to signal that it's not allowed. In the name of backwards compatibility we
|
||||
// test for both 404 (NotFoundException) and 403 (ForbiddenException) here.
|
||||
if (isEnterprise && (retryEx is NotFoundException || retryEx is ForbiddenException || retryEx.StatusCode == (HttpStatusCode)422))
|
||||
return GetUserFromApi();
|
||||
|
||||
// Other errors are "real" so we pass them along:
|
||||
return Observable.Throw<UserAndScopes>(retryEx);
|
||||
})
|
||||
.ObserveOn(RxApp.MainThreadScheduler)
|
||||
.Catch<UserAndScopes, Exception>(ex =>
|
||||
{
|
||||
// If we get here, we have an actual login failure:
|
||||
if (ex is TwoFactorChallengeFailedException)
|
||||
{
|
||||
return Observable.Return(unverifiedUser);
|
||||
}
|
||||
if (ex is AuthorizationException)
|
||||
{
|
||||
return Observable.Return(default(UserAndScopes));
|
||||
}
|
||||
return Observable.Throw<UserAndScopes>(ex);
|
||||
})
|
||||
.SelectMany(LoginWithApiUser)
|
||||
.PublishAsync();
|
||||
}
|
||||
|
||||
public IObservable<Unit> LogOut()
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.Composition;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
@ -10,9 +9,9 @@ using System.Reactive.Subjects;
|
|||
using Akavache;
|
||||
using GitHub.Authentication;
|
||||
using GitHub.Caches;
|
||||
using GitHub.Extensions.Reactive;
|
||||
using GitHub.Factories;
|
||||
using GitHub.Primitives;
|
||||
using GitHub.Services;
|
||||
using NullGuard;
|
||||
using ReactiveUI;
|
||||
|
||||
|
@ -81,11 +80,11 @@ namespace GitHub.Models
|
|||
disposables.Add(
|
||||
connectionManager.Connections.CreateDerivedCollection(x => x)
|
||||
.ItemsRemoved
|
||||
.Select(x =>
|
||||
.SelectMany(async x =>
|
||||
{
|
||||
var host = LookupHost(x.HostAddress);
|
||||
if (host.Address != x.HostAddress)
|
||||
host = RepositoryHostFactory.Create(x.HostAddress);
|
||||
host = await RepositoryHostFactory.Create(x.HostAddress);
|
||||
return host;
|
||||
})
|
||||
.Select(h => LogOut(h))
|
||||
|
@ -128,36 +127,42 @@ namespace GitHub.Models
|
|||
string password)
|
||||
{
|
||||
var isDotCom = HostAddress.GitHubDotComHostAddress == address;
|
||||
var host = RepositoryHostFactory.Create(address);
|
||||
return host.LogIn(usernameOrEmail, password)
|
||||
.Catch<AuthenticationResult, Exception>(Observable.Throw<AuthenticationResult>)
|
||||
.Do(result =>
|
||||
{
|
||||
bool successful = result.IsSuccess();
|
||||
log.Info(CultureInfo.InvariantCulture, "Log in to {3} host '{0}' with username '{1}' {2}",
|
||||
address.ApiUri,
|
||||
usernameOrEmail,
|
||||
successful ? "SUCCEEDED" : "FAILED",
|
||||
isDotCom ? "GitHub.com" : address.WebUri.Host
|
||||
);
|
||||
if (successful)
|
||||
|
||||
return Observable.Defer(async () =>
|
||||
{
|
||||
var host = await RepositoryHostFactory.Create(address);
|
||||
|
||||
return host.LogIn(usernameOrEmail, password)
|
||||
.Catch<AuthenticationResult, Exception>(Observable.Throw<AuthenticationResult>)
|
||||
.ObserveOn(RxApp.MainThreadScheduler)
|
||||
.Do(result =>
|
||||
{
|
||||
// Make sure that GitHubHost/EnterpriseHost are set when the connections
|
||||
// changed event is raised and likewise that the connection is added when
|
||||
// the property changed notification is sent.
|
||||
if (isDotCom)
|
||||
githubHost = host;
|
||||
else
|
||||
enterpriseHost = host;
|
||||
bool successful = result.IsSuccess();
|
||||
log.Info(CultureInfo.InvariantCulture, "Log in to {3} host '{0}' with username '{1}' {2}",
|
||||
address.ApiUri,
|
||||
usernameOrEmail,
|
||||
successful ? "SUCCEEDED" : "FAILED",
|
||||
isDotCom ? "GitHub.com" : address.WebUri.Host
|
||||
);
|
||||
if (successful)
|
||||
{
|
||||
// Make sure that GitHubHost/EnterpriseHost are set when the connections
|
||||
// changed event is raised and likewise that the connection is added when
|
||||
// the property changed notification is sent.
|
||||
if (isDotCom)
|
||||
githubHost = host;
|
||||
else
|
||||
enterpriseHost = host;
|
||||
|
||||
connectionManager.AddConnection(address, usernameOrEmail);
|
||||
connectionManager.AddConnection(address, usernameOrEmail);
|
||||
|
||||
if (isDotCom)
|
||||
this.RaisePropertyChanged(nameof(GitHubHost));
|
||||
else
|
||||
this.RaisePropertyChanged(nameof(EnterpriseHost));
|
||||
}
|
||||
});
|
||||
if (isDotCom)
|
||||
this.RaisePropertyChanged(nameof(GitHubHost));
|
||||
else
|
||||
this.RaisePropertyChanged(nameof(EnterpriseHost));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -169,20 +174,25 @@ namespace GitHub.Models
|
|||
public IObservable<AuthenticationResult> LogInFromCache(HostAddress address)
|
||||
{
|
||||
var isDotCom = HostAddress.GitHubDotComHostAddress == address;
|
||||
var host = RepositoryHostFactory.Create(address);
|
||||
return host.LogInFromCache()
|
||||
.Catch<AuthenticationResult, Exception>(Observable.Throw<AuthenticationResult>)
|
||||
.Do(result =>
|
||||
{
|
||||
bool successful = result.IsSuccess();
|
||||
if (successful)
|
||||
|
||||
return Observable.Defer(async () =>
|
||||
{
|
||||
var host = await RepositoryHostFactory.Create(address);
|
||||
return host.LogInFromCache()
|
||||
.Catch<AuthenticationResult, Exception>(Observable.Throw<AuthenticationResult>)
|
||||
.ObserveOn(RxApp.MainThreadScheduler)
|
||||
.Do(result =>
|
||||
{
|
||||
if (isDotCom)
|
||||
GitHubHost = host;
|
||||
else
|
||||
EnterpriseHost = host;
|
||||
}
|
||||
});
|
||||
bool successful = result.IsSuccess();
|
||||
if (successful)
|
||||
{
|
||||
if (isDotCom)
|
||||
GitHubHost = host;
|
||||
else
|
||||
EnterpriseHost = host;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public IObservable<Unit> LogOut(IRepositoryHost host)
|
||||
|
@ -190,6 +200,7 @@ namespace GitHub.Models
|
|||
var address = host.Address;
|
||||
var isDotCom = HostAddress.GitHubDotComHostAddress == address;
|
||||
return host.LogOut()
|
||||
.ObserveOn(RxApp.MainThreadScheduler)
|
||||
.Do(result =>
|
||||
{
|
||||
// reset the logged out host property to null
|
||||
|
|
|
@ -47,13 +47,14 @@ namespace GitHub.ViewModels
|
|||
if (ex.IsCriticalException()) return;
|
||||
|
||||
log.Info(string.Format(CultureInfo.InvariantCulture, "Error logging into '{0}' as '{1}'", BaseUri, UsernameOrEmail), ex);
|
||||
|
||||
if (ex is Octokit.ForbiddenException)
|
||||
{
|
||||
UserError.Throw(new UserError(Resources.LoginFailedForbiddenMessage));
|
||||
Error = new UserError(Resources.LoginFailedForbiddenMessage, ex.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
UserError.Throw(new UserError(ex.Message));
|
||||
Error = new UserError(ex.Message);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -127,6 +128,15 @@ namespace GitHub.ViewModels
|
|||
get { return canLogin.Value; }
|
||||
}
|
||||
|
||||
UserError error;
|
||||
[AllowNull]
|
||||
public UserError Error
|
||||
{
|
||||
[return: AllowNull]
|
||||
get { return error; }
|
||||
set { this.RaiseAndSetIfChanged(ref error, value); }
|
||||
}
|
||||
|
||||
protected abstract IObservable<AuthenticationResult> LogIn(object args);
|
||||
|
||||
protected IObservable<AuthenticationResult> LogInToHost(HostAddress hostAddress)
|
||||
|
@ -142,15 +152,15 @@ namespace GitHub.ViewModels
|
|||
switch (authResult)
|
||||
{
|
||||
case AuthenticationResult.CredentialFailure:
|
||||
UserError.Throw(new UserError(
|
||||
Error = new UserError(
|
||||
Resources.LoginFailedText,
|
||||
Resources.LoginFailedMessage,
|
||||
new[] { NavigateForgotPassword }));
|
||||
new[] { NavigateForgotPassword });
|
||||
break;
|
||||
case AuthenticationResult.VerificationFailure:
|
||||
break;
|
||||
case AuthenticationResult.EnterpriseServerNotFound:
|
||||
UserError.Throw(new UserError(Resources.CouldNotConnectToGitHub));
|
||||
Error = new UserError(Resources.CouldNotConnectToGitHub);
|
||||
break;
|
||||
}
|
||||
})
|
||||
|
|
|
@ -312,6 +312,7 @@ namespace GitHub.ViewModels
|
|||
/// <param name="files">The pull request's changed files.</param>
|
||||
public async Task Load(IPullRequestModel pullRequest)
|
||||
{
|
||||
var firstLoad = (Model == null);
|
||||
Model = pullRequest;
|
||||
Session = await sessionManager.GetSession(pullRequest);
|
||||
Title = Resources.PullRequestNavigationItemText + " #" + pullRequest.Number;
|
||||
|
@ -391,6 +392,11 @@ namespace GitHub.ViewModels
|
|||
|
||||
IsLoading = IsBusy = false;
|
||||
|
||||
if (firstLoad)
|
||||
{
|
||||
usageTracker.IncrementPullRequestOpened().Forget();
|
||||
}
|
||||
|
||||
if (!isInCheckout)
|
||||
{
|
||||
pullRequestsService.RemoveUnusedRemotes(Repository).Subscribe(_ => { });
|
||||
|
|
|
@ -31,7 +31,7 @@ namespace GitHub.ViewModels
|
|||
[ImportingConstructor]
|
||||
public TwoFactorDialogViewModel(
|
||||
IVisualStudioBrowser browser,
|
||||
ITwoFactorChallengeHandler twoFactorChallengeHandler)
|
||||
IDelegatingTwoFactorChallengeHandler twoFactorChallengeHandler)
|
||||
{
|
||||
Title = Resources.TwoFactorTitle;
|
||||
twoFactorChallengeHandler.SetViewModel(this);
|
||||
|
@ -42,6 +42,7 @@ namespace GitHub.ViewModels
|
|||
(code, busy) => !string.IsNullOrEmpty(code.Value) && code.Value.Length == 6 && !busy.Value);
|
||||
|
||||
OkCommand = ReactiveCommand.Create(canVerify);
|
||||
Cancel.Subscribe(_ => TwoFactorType = TwoFactorType.None);
|
||||
NavigateLearnMore = ReactiveCommand.Create();
|
||||
NavigateLearnMore.Subscribe(x => browser.OpenUrl(GitHubUrls.TwoFactorLearnMore));
|
||||
//TODO: ShowHelpCommand.Subscribe(x => browser.OpenUrl(twoFactorHelpUri));
|
||||
|
@ -80,28 +81,25 @@ namespace GitHub.ViewModels
|
|||
.ToProperty(this, x => x.IsSms);
|
||||
}
|
||||
|
||||
public IObservable<RecoveryOptionResult> Show(UserError userError)
|
||||
public IObservable<TwoFactorChallengeResult> Show(UserError userError)
|
||||
{
|
||||
IsBusy = false;
|
||||
var error = userError as TwoFactorRequiredUserError;
|
||||
Debug.Assert(error != null,
|
||||
String.Format(CultureInfo.InvariantCulture, "The user error is '{0}' not a TwoFactorRequiredUserError", userError));
|
||||
InvalidAuthenticationCode = error.RetryFailed;
|
||||
IsAuthenticationCodeSent = false;
|
||||
TwoFactorType = error.TwoFactorType;
|
||||
var ok = OkCommand
|
||||
.Do(_ => IsBusy = true)
|
||||
.Select(_ => AuthenticationCode == null
|
||||
? RecoveryOptionResult.CancelOperation
|
||||
: RecoveryOptionResult.RetryOperation)
|
||||
.Do(_ => error.ChallengeResult = AuthenticationCode != null
|
||||
? new TwoFactorChallengeResult(AuthenticationCode)
|
||||
: null);
|
||||
? null
|
||||
: new TwoFactorChallengeResult(AuthenticationCode));
|
||||
var resend = ResendCodeCommand.Select(_ => RecoveryOptionResult.RetryOperation)
|
||||
.Do(_ => error.ChallengeResult = TwoFactorChallengeResult.RequestResendCode);
|
||||
var cancel = Cancel.Select(_ => RecoveryOptionResult.CancelOperation);
|
||||
return Observable.Merge(ok, cancel, resend)
|
||||
.Take(1)
|
||||
.Do(_ => IsAuthenticationCodeSent = error.ChallengeResult == TwoFactorChallengeResult.RequestResendCode);
|
||||
.Select(_ => TwoFactorChallengeResult.RequestResendCode)
|
||||
.Do(_ => IsAuthenticationCodeSent = true);
|
||||
var cancel = Cancel.Select(_ => default(TwoFactorChallengeResult));
|
||||
return Observable.Merge(ok, cancel, resend).Take(1);
|
||||
}
|
||||
|
||||
public TwoFactorType TwoFactorType
|
||||
|
|
|
@ -9,6 +9,10 @@ namespace GitHub.Api
|
|||
public interface IApiClient
|
||||
{
|
||||
HostAddress HostAddress { get; }
|
||||
|
||||
// HACK: This is temporary. Should be removed in login refactor timeframe.
|
||||
IGitHubClient GitHubClient { get; }
|
||||
|
||||
IObservable<Repository> CreateRepository(NewRepository repository, string login, bool isUser);
|
||||
IObservable<Gist> CreateGist(NewGist newGist);
|
||||
IObservable<UserAndScopes> GetUser();
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
using System;
|
||||
using Octokit;
|
||||
using GitHub.ViewModels;
|
||||
using GitHub.Api;
|
||||
|
||||
namespace GitHub.Authentication
|
||||
{
|
||||
public interface ITwoFactorChallengeHandler
|
||||
public interface IDelegatingTwoFactorChallengeHandler : ITwoFactorChallengeHandler
|
||||
{
|
||||
void SetViewModel(IViewModel vm);
|
||||
IViewModel CurrentViewModel { get; }
|
||||
IObservable<TwoFactorChallengeResult> HandleTwoFactorException(TwoFactorAuthorizationException exception);
|
||||
}
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
using System.Reactive.Linq;
|
||||
using GitHub.Models;
|
||||
using System;
|
||||
using ReactiveUI;
|
||||
using GitHub.Primitives;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Reactive.Linq;
|
||||
using GitHub.Models;
|
||||
using GitHub.Primitives;
|
||||
using GitHub.Services;
|
||||
|
||||
namespace GitHub.Extensions
|
||||
{
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
using GitHub.Api;
|
||||
using GitHub.Primitives;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Octokit;
|
||||
|
||||
namespace GitHub.Factories
|
||||
{
|
||||
public interface IApiClientFactory
|
||||
{
|
||||
IApiClient Create(HostAddress hostAddress);
|
||||
Task<IGitHubClient> CreateGitHubClient(HostAddress hostAddress);
|
||||
|
||||
Task<IApiClient> Create(HostAddress hostAddress);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Models;
|
||||
using GitHub.Primitives;
|
||||
|
||||
|
@ -6,7 +7,7 @@ namespace GitHub.Factories
|
|||
{
|
||||
public interface IRepositoryHostFactory : IDisposable
|
||||
{
|
||||
IRepositoryHost Create(HostAddress hostAddress);
|
||||
Task<IRepositoryHost> Create(HostAddress hostAddress);
|
||||
void Remove(IRepositoryHost host);
|
||||
}
|
||||
}
|
|
@ -12,7 +12,7 @@
|
|||
<AssemblyName>GitHub.Exports.Reactive</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
|
||||
<NuGetPackageImportStamp>
|
||||
</NuGetPackageImportStamp>
|
||||
<OutputPath>bin\$(Configuration)\</OutputPath>
|
||||
|
@ -155,7 +155,7 @@
|
|||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Factories\IApiClientFactory.cs" />
|
||||
<Compile Include="Authentication\ITwoFactorChallengeHandler.cs" />
|
||||
<Compile Include="Authentication\IDelegatingTwoFactorChallengeHandler.cs" />
|
||||
<Compile Include="Factories\IRepositoryHostFactory.cs" />
|
||||
<Compile Include="Models\IRepositoryHost.cs" />
|
||||
<Compile Include="Collections\ISelectable.cs" />
|
||||
|
@ -188,6 +188,10 @@
|
|||
<Project>{252ce1c2-027a-4445-a3c2-e4d6c80a935a}</Project>
|
||||
<Name>Splat-Net45</Name>
|
||||
</ProjectReference>
|
||||
<ProjectReference Include="..\GitHub.Api\GitHub.Api.csproj">
|
||||
<Project>{B389ADAF-62CC-486E-85B4-2D8B078DF763}</Project>
|
||||
<Name>GitHub.Api</Name>
|
||||
</ProjectReference>
|
||||
<ProjectReference Include="..\GitHub.Exports\GitHub.Exports.csproj">
|
||||
<Project>{9aea02db-02b5-409c-b0ca-115d05331a6b}</Project>
|
||||
<Name>GitHub.Exports</Name>
|
||||
|
|
|
@ -64,5 +64,10 @@ namespace GitHub.ViewModels
|
|||
/// a GitHub.com lost password flow.
|
||||
/// </summary>
|
||||
IRecoveryCommand NavigateForgotPassword { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets an error to display to the user.
|
||||
/// </summary>
|
||||
UserError Error { get; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using GitHub.Validation;
|
||||
using Octokit;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace GitHub.ViewModels
|
||||
|
@ -10,7 +11,7 @@ namespace GitHub.ViewModels
|
|||
ReactiveCommand<object> NavigateLearnMore { get; }
|
||||
ReactiveCommand<object> ResendCodeCommand { get; }
|
||||
|
||||
IObservable<RecoveryOptionResult> Show(UserError error);
|
||||
IObservable<TwoFactorChallengeResult> Show(UserError error);
|
||||
|
||||
bool IsSms { get; }
|
||||
bool IsAuthenticationCodeSent { get; }
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
using GitHub.Primitives;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Primitives;
|
||||
|
||||
namespace GitHub.Api
|
||||
{
|
||||
public interface ISimpleApiClientFactory
|
||||
{
|
||||
ISimpleApiClient Create(UriString repositoryUrl);
|
||||
Task<ISimpleApiClient> Create(UriString repositoryUrl);
|
||||
void ClearFromCache(ISimpleApiClient client);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<AssemblyName>GitHub.Exports</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
|
||||
<NuGetPackageImportStamp>
|
||||
</NuGetPackageImportStamp>
|
||||
<OutputPath>bin\$(Configuration)\</OutputPath>
|
||||
|
@ -162,11 +162,13 @@
|
|||
<Compile Include="Helpers\ThreadingHelper.cs" />
|
||||
<Compile Include="Info\ApplicationInfo.cs" />
|
||||
<Compile Include="Models\BranchModel.cs" />
|
||||
<Compile Include="Models\ConnectionDetails.cs" />
|
||||
<Compile Include="Models\CloneDialogResult.cs" />
|
||||
<Compile Include="Models\GitReferenceModel.cs" />
|
||||
<Compile Include="Models\IAccount.cs" />
|
||||
<Compile Include="Models\IBranch.cs" />
|
||||
<Compile Include="Models\IConnectionManager.cs" />
|
||||
<Compile Include="Services\IConnectionCache.cs" />
|
||||
<Compile Include="Services\IConnectionManager.cs" />
|
||||
<Compile Include="Models\IExportFactoryProvider.cs" />
|
||||
<Compile Include="Models\IPullRequestFileModel.cs" />
|
||||
<Compile Include="Models\IRepositoryModel.cs" />
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
using System;
|
||||
using GitHub.Primitives;
|
||||
|
||||
namespace GitHub.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents details about a connection stored in an <see cref="IConnectionCache"/>.
|
||||
/// </summary>
|
||||
public struct ConnectionDetails : IEquatable<ConnectionDetails>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConnectionDetails"/> struct.
|
||||
/// </summary>
|
||||
/// <param name="hostAddress">The address of the host.</param>
|
||||
/// <param name="userName">The username for the host.</param>
|
||||
public ConnectionDetails(string hostAddress, string userName)
|
||||
{
|
||||
HostAddress = HostAddress.Create(hostAddress);
|
||||
UserName = userName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConnectionDetails"/> struct.
|
||||
/// </summary>
|
||||
/// <param name="hostAddress">The address of the host.</param>
|
||||
/// <param name="userName">The username for the host.</param>
|
||||
public ConnectionDetails(HostAddress hostAddress, string userName)
|
||||
{
|
||||
HostAddress = hostAddress;
|
||||
UserName = userName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the address of the host.
|
||||
/// </summary>
|
||||
public HostAddress HostAddress { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the username for the host.
|
||||
/// </summary>
|
||||
public string UserName { get; }
|
||||
|
||||
public bool Equals(ConnectionDetails other)
|
||||
{
|
||||
if (ReferenceEquals(this, other))
|
||||
return true;
|
||||
|
||||
return HostAddress.Equals(other.HostAddress) &&
|
||||
string.Equals(UserName, other.UserName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public override bool Equals(object obj)
|
||||
{
|
||||
return obj is ConnectionDetails && Equals((ConnectionDetails)obj);
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
return (HostAddress.GetHashCode()*397) ^ StringComparer.InvariantCultureIgnoreCase.GetHashCode(UserName);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool operator ==(ConnectionDetails left, ConnectionDetails right)
|
||||
{
|
||||
return Equals(left, right);
|
||||
}
|
||||
|
||||
public static bool operator !=(ConnectionDetails left, ConnectionDetails right)
|
||||
{
|
||||
return !Equals(left, right);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -57,13 +57,13 @@ namespace GitHub.Primitives
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Base URL to the host. For example, "https://github.com" or "https://ghe.io"
|
||||
/// The Base URL to the host. For example, "https://github.com" or "https://github-enterprise.com"
|
||||
/// </summary>
|
||||
public Uri WebUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The Base Url to the host's API endpoint. For example, "https://api.github.com" or
|
||||
/// "https://ghe.io/api/v3"
|
||||
/// "https://github-enterprise.com/api/v3"
|
||||
/// </summary>
|
||||
public Uri ApiUri { get; set; }
|
||||
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Models;
|
||||
|
||||
namespace GitHub.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads the configured connections from a cache.
|
||||
/// </summary>
|
||||
public interface IConnectionCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads the configured connections.
|
||||
/// </summary>
|
||||
/// <returns>A task returning the collection of configured collections.</returns>
|
||||
Task<IEnumerable<ConnectionDetails>> Load();
|
||||
|
||||
/// <summary>
|
||||
/// Saves the configured connections.
|
||||
/// </summary>
|
||||
/// <param name="connections">The collection of configured collections to save.</param>
|
||||
/// <returns>A task tracking the operation.</returns>
|
||||
Task Save(IEnumerable<ConnectionDetails> connections);
|
||||
}
|
||||
}
|
|
@ -1,10 +1,11 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using GitHub.Primitives;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Models;
|
||||
using GitHub.Primitives;
|
||||
|
||||
namespace GitHub.Models
|
||||
namespace GitHub.Services
|
||||
{
|
||||
public interface IConnectionManager
|
||||
{
|
|
@ -19,6 +19,7 @@ namespace GitHub.Services
|
|||
Task IncrementPullRequestCheckOutCount(bool fork);
|
||||
Task IncrementPullRequestPullCount(bool fork);
|
||||
Task IncrementPullRequestPushCount(bool fork);
|
||||
Task IncrementPullRequestOpened();
|
||||
Task IncrementWelcomeDocsClicks();
|
||||
Task IncrementWelcomeTrainingClicks();
|
||||
Task IncrementGitHubPaneHelpClicks();
|
||||
|
|
|
@ -14,6 +14,7 @@ namespace GitHub.VisualStudio
|
|||
public const string CodeContainerProviderId = "6CE146CB-EF57-4F2C-A93F-5BA685317660";
|
||||
public const string InlineReviewsPackageId = "248325BE-4A2D-4111-B122-E7D59BF73A35";
|
||||
public const string TeamExplorerWelcomeMessage = "C529627F-8AA6-4FDB-82EB-4BFB7DB753C3";
|
||||
public const string LoginManagerId = "7BA2071A-790A-4F95-BE4A-0EEAA5928AAF";
|
||||
|
||||
// VisualStudio IDs
|
||||
public const string GitSccProviderId = "11B8E6D7-C08B-4385-B321-321078CDD1F8";
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
<RunCodeAnalysis>true</RunCodeAnalysis>
|
||||
<CodeAnalysisRuleSet>..\common\GitHubVS.ruleset</CodeAnalysisRuleSet>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
|
||||
<OutputPath>bin\$(Configuration)\</OutputPath>
|
||||
<TargetFrameworkProfile />
|
||||
</PropertyGroup>
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
<RunCodeAnalysis>true</RunCodeAnalysis>
|
||||
<CodeAnalysisRuleSet>..\common\GitHubVS.ruleset</CodeAnalysisRuleSet>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
|
||||
<OutputPath>bin\$(Configuration)\</OutputPath>
|
||||
<TargetFrameworkProfile />
|
||||
</PropertyGroup>
|
||||
|
|
|
@ -57,7 +57,7 @@ namespace GitHub.InlineReviews.Commands
|
|||
|
||||
var session = await sessionManager.GetSession(pullRequest);
|
||||
var address = HostAddress.Create(session.Repository.CloneUrl);
|
||||
var apiClient = apiClientFactory.Create(address);
|
||||
var apiClient = await apiClientFactory.Create(address);
|
||||
await window.Initialize(session, apiClient);
|
||||
|
||||
var windowFrame = (IVsWindowFrame)window.Frame;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.ComponentModel.Composition;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Api;
|
||||
using GitHub.Extensions;
|
||||
using GitHub.Factories;
|
||||
|
@ -127,12 +128,6 @@ namespace GitHub.InlineReviews.Services
|
|||
return trackingPoint;
|
||||
}
|
||||
|
||||
IApiClient CreateApiClient(ILocalRepositoryModel repository)
|
||||
{
|
||||
var hostAddress = HostAddress.Create(repository.CloneUrl.Host);
|
||||
return apiClientFactory.Create(hostAddress);
|
||||
}
|
||||
|
||||
void ExpandCollapsedRegions(ITextView textView, SnapshotSpan span)
|
||||
{
|
||||
var outlining = outliningService.GetOutliningManager(textView);
|
||||
|
|
|
@ -138,7 +138,7 @@ namespace GitHub.InlineReviews.ViewModels
|
|||
var thread = file.InlineCommentThreads.FirstOrDefault(x =>
|
||||
x.LineNumber == lineNumber &&
|
||||
(!leftBuffer || x.DiffLineType == DiffChangeType.Delete));
|
||||
var apiClient = CreateApiClient(session.Repository);
|
||||
var apiClient = await CreateApiClient(session.Repository);
|
||||
|
||||
if (thread != null)
|
||||
{
|
||||
|
@ -180,7 +180,7 @@ namespace GitHub.InlineReviews.ViewModels
|
|||
fileSubscription = file.WhenAnyValue(x => x.InlineCommentThreads).Subscribe(_ => UpdateThread().Forget());
|
||||
}
|
||||
|
||||
IApiClient CreateApiClient(ILocalRepositoryModel repository)
|
||||
Task<IApiClient> CreateApiClient(ILocalRepositoryModel repository)
|
||||
{
|
||||
var hostAddress = HostAddress.Create(repository.CloneUrl.Host);
|
||||
return apiClientFactory.Create(hostAddress);
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
|
||||
<UseCodeBase>true</UseCodeBase>
|
||||
<TargetFrameworkProfile />
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
|
||||
<NuGetPackageImportStamp>
|
||||
</NuGetPackageImportStamp>
|
||||
</PropertyGroup>
|
||||
|
|
|
@ -259,7 +259,8 @@ namespace GitHub.VisualStudio.TeamExplorer.Connect
|
|||
try
|
||||
{
|
||||
// TODO: Cache the icon state.
|
||||
var repo = await ApiFactory.Create(newrepo.CloneUrl).GetRepository();
|
||||
var api = await ApiFactory.Create(newrepo.CloneUrl);
|
||||
var repo = await api.GetRepository();
|
||||
newrepo.SetIcon(repo.Private, repo.Fork);
|
||||
}
|
||||
catch
|
||||
|
@ -283,7 +284,8 @@ namespace GitHub.VisualStudio.TeamExplorer.Connect
|
|||
try
|
||||
{
|
||||
// TODO: Cache the icon state.
|
||||
var repo = await ApiFactory.Create(r.CloneUrl).GetRepository();
|
||||
var api = await ApiFactory.Create(r.CloneUrl);
|
||||
var repo = await api.GetRepository();
|
||||
r.SetIcon(repo.Private, repo.Fork);
|
||||
}
|
||||
catch
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<OutputPath>..\..\build\$(Configuration)\</OutputPath>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
|
||||
<NuGetPackageImportStamp>
|
||||
</NuGetPackageImportStamp>
|
||||
<TargetFrameworkProfile />
|
||||
|
|
|
@ -26,6 +26,7 @@ namespace GitHub.VisualStudio.TeamExplorer.Home
|
|||
{
|
||||
public const string GitHubHomeSectionId = "72008232-2104-4FA0-A189-61B0C6F91198";
|
||||
const string TrainingUrl = "https://services.github.com/on-demand/windows/visual-studio";
|
||||
readonly static Guid welcomeMessageGuid = new Guid(Guids.TeamExplorerWelcomeMessage);
|
||||
|
||||
readonly IVisualStudioBrowser visualStudioBrowser;
|
||||
readonly ITeamExplorerServices teamExplorerServices;
|
||||
|
@ -53,25 +54,6 @@ namespace GitHub.VisualStudio.TeamExplorer.Home
|
|||
var openOnGitHub = ReactiveCommand.Create();
|
||||
openOnGitHub.Subscribe(_ => DoOpenOnGitHub());
|
||||
OpenOnGitHub = openOnGitHub;
|
||||
|
||||
// We want to display a welcome message but only if Team Explorer isn't
|
||||
// already displaying the "Install 3rd Party Tools" message. To do this
|
||||
// we need to set a timer and check in the tick as at this point the message
|
||||
// won't be initialized.
|
||||
if (!settings.HideTeamExplorerWelcomeMessage)
|
||||
{
|
||||
var timer = new DispatcherTimer();
|
||||
timer.Interval = new TimeSpan(10);
|
||||
timer.Tick += (s, e) =>
|
||||
{
|
||||
timer.Stop();
|
||||
if (!IsGitToolsMessageVisible())
|
||||
{
|
||||
ShowWelcomeMessage();
|
||||
}
|
||||
};
|
||||
timer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
bool IsGitToolsMessageVisible()
|
||||
|
@ -93,11 +75,23 @@ namespace GitHub.VisualStudio.TeamExplorer.Home
|
|||
RepoName = ActiveRepoName;
|
||||
RepoUrl = ActiveRepoUri.ToString();
|
||||
Icon = GetIcon(false, true, false);
|
||||
|
||||
// We want to display a welcome message but only if Team Explorer isn't
|
||||
// already displaying the "Install 3rd Party Tools" message and the current repo is hosted on GitHub.
|
||||
if (!settings.HideTeamExplorerWelcomeMessage && !IsGitToolsMessageVisible())
|
||||
{
|
||||
ShowWelcomeMessage();
|
||||
}
|
||||
|
||||
Debug.Assert(SimpleApiClient != null,
|
||||
"If we're in this block, simpleApiClient cannot be null. It was created by UpdateStatus");
|
||||
var repo = await SimpleApiClient.GetRepository();
|
||||
Icon = GetIcon(repo.Private, true, repo.Fork);
|
||||
IsLoggedIn = IsUserAuthenticated();
|
||||
IsLoggedIn = await IsUserAuthenticated();
|
||||
}
|
||||
else
|
||||
{
|
||||
teamExplorerServices.HideNotification(welcomeMessageGuid);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -106,8 +100,9 @@ namespace GitHub.VisualStudio.TeamExplorer.Home
|
|||
IsVisible = await IsAGitHubRepo();
|
||||
if (IsVisible)
|
||||
{
|
||||
IsLoggedIn = IsUserAuthenticated();
|
||||
IsLoggedIn = await IsUserAuthenticated();
|
||||
}
|
||||
|
||||
base.Refresh();
|
||||
}
|
||||
|
||||
|
@ -149,7 +144,6 @@ namespace GitHub.VisualStudio.TeamExplorer.Home
|
|||
|
||||
void ShowWelcomeMessage()
|
||||
{
|
||||
var welcomeMessageGuid = new Guid(Guids.TeamExplorerWelcomeMessage);
|
||||
teamExplorerServices.ShowMessage(
|
||||
Resources.TeamExplorerWelcomeMessage,
|
||||
new RelayCommand(o =>
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<OutputPath>..\..\build\$(Configuration)\</OutputPath>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
|
||||
<NuGetPackageImportStamp>
|
||||
</NuGetPackageImportStamp>
|
||||
<TargetFrameworkProfile />
|
||||
|
|
|
@ -32,18 +32,6 @@ namespace GitHub.UI
|
|||
{
|
||||
DataContext = result;
|
||||
});
|
||||
|
||||
Unloaded += (o, e) =>
|
||||
{
|
||||
if (whenAnyShowingMessage != null)
|
||||
{
|
||||
whenAnyShowingMessage.Dispose();
|
||||
}
|
||||
if (whenAnyDataContext != null)
|
||||
{
|
||||
whenAnyDataContext.Dispose();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty IconMarginProperty = DependencyProperty.Register("IconMargin", typeof(Thickness), typeof(UserErrorMessages), new PropertyMetadata(new Thickness(0,0,8,0)));
|
||||
|
|
|
@ -42,6 +42,13 @@ namespace GitHub.UI
|
|||
typeof(ViewBase),
|
||||
new FrameworkPropertyMetadata());
|
||||
|
||||
static readonly DependencyProperty ShowBusyStateProperty =
|
||||
DependencyProperty.Register(
|
||||
nameof(ShowBusyState),
|
||||
typeof(bool),
|
||||
typeof(ViewBase),
|
||||
new FrameworkPropertyMetadata(true));
|
||||
|
||||
public static readonly DependencyProperty HasBusyStateProperty = HasBusyStatePropertyKey.DependencyProperty;
|
||||
public static readonly DependencyProperty IsBusyProperty = IsBusyPropertyKey.DependencyProperty;
|
||||
public static readonly DependencyProperty IsLoadingProperty = IsLoadingPropertyKey.DependencyProperty;
|
||||
|
@ -83,6 +90,15 @@ namespace GitHub.UI
|
|||
protected set { SetValue(IsLoadingPropertyKey, value); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to display the view model's busy state.
|
||||
/// </summary>
|
||||
public bool ShowBusyState
|
||||
{
|
||||
get { return (bool)GetValue(ShowBusyStateProperty); }
|
||||
set { SetValue(ShowBusyStateProperty, value); }
|
||||
}
|
||||
|
||||
internal ViewBase()
|
||||
{
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
<RunCodeAnalysis>true</RunCodeAnalysis>
|
||||
<CodeAnalysisRuleSet>..\common\GitHubVS.ruleset</CodeAnalysisRuleSet>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
|
||||
<OutputPath>bin\$(Configuration)\</OutputPath>
|
||||
<TargetFrameworkProfile />
|
||||
</PropertyGroup>
|
||||
|
|
|
@ -32,16 +32,28 @@
|
|||
|
||||
<ui:GitHubProgressBar Foreground="{DynamicResource GitHubAccentBrush}"
|
||||
IsIndeterminate="True"
|
||||
Style="{DynamicResource GitHubProgressBar}"
|
||||
Visibility="{TemplateBinding IsBusy, Converter={ui:BooleanToHiddenVisibilityConverter}}"/>
|
||||
Style="{DynamicResource GitHubProgressBar}">
|
||||
<ui:GitHubProgressBar.Visibility>
|
||||
<MultiBinding Converter="{ui:MultiBooleanToVisibilityConverter}">
|
||||
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="IsBusy"/>
|
||||
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="ShowBusyState"/>
|
||||
</MultiBinding>
|
||||
</ui:GitHubProgressBar.Visibility>
|
||||
</ui:GitHubProgressBar>
|
||||
|
||||
<c:Spinner Name="spinner"
|
||||
Grid.Row="1"
|
||||
Width="48"
|
||||
Height="48"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="{TemplateBinding IsLoading, Converter={ui:BooleanToVisibilityConverter}}"/>
|
||||
VerticalAlignment="Center">
|
||||
<c:Spinner.Visibility>
|
||||
<MultiBinding Converter="{ui:MultiBooleanToVisibilityConverter}">
|
||||
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="IsLoading"/>
|
||||
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="ShowBusyState"/>
|
||||
</MultiBinding>
|
||||
</c:Spinner.Visibility>
|
||||
</c:Spinner>
|
||||
|
||||
<ContentPresenter Grid.Row="1"
|
||||
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using NullGuard;
|
||||
|
||||
namespace GitHub.UI
|
||||
{
|
||||
[Localizability(LocalizationCategory.NeverLocalize)]
|
||||
public sealed class MultiBooleanToVisibilityConverter : MultiValueConverterMarkupExtension<MultiBooleanToVisibilityConverter>
|
||||
{
|
||||
readonly System.Windows.Controls.BooleanToVisibilityConverter converter = new System.Windows.Controls.BooleanToVisibilityConverter();
|
||||
|
||||
public override object Convert(
|
||||
[AllowNull]object[] value,
|
||||
[AllowNull]Type targetType,
|
||||
[AllowNull]object parameter,
|
||||
[AllowNull]CultureInfo culture)
|
||||
{
|
||||
return value.OfType<bool>().All(x => x) ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
public override object[] ConvertBack(
|
||||
[AllowNull]object value,
|
||||
[AllowNull]Type[] targetType,
|
||||
[AllowNull]object parameter,
|
||||
[AllowNull]CultureInfo culture)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -14,7 +14,7 @@
|
|||
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
|
||||
<NuGetPackageImportStamp>
|
||||
</NuGetPackageImportStamp>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
|
||||
<OutputPath>bin\$(Configuration)\</OutputPath>
|
||||
<TargetFrameworkProfile />
|
||||
</PropertyGroup>
|
||||
|
@ -91,6 +91,7 @@
|
|||
<DependentUpon>Spinner.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="Converters\AllCapsConverter.cs" />
|
||||
<Compile Include="Converters\MultiBooleanToVisibilityConverter.cs" />
|
||||
<Compile Include="Converters\NullToVisibilityConverter.cs" />
|
||||
<Compile Include="Converters\BranchNameConverter.cs" />
|
||||
<Compile Include="Converters\CountToVisibilityConverter.cs" />
|
||||
|
|
|
@ -112,7 +112,7 @@ namespace GitHub.VisualStudio.Base
|
|||
return RepositoryOrigin.Other;
|
||||
|
||||
Debug.Assert(apiFactory != null, "apiFactory cannot be null. Did you call the right constructor?");
|
||||
SimpleApiClient = apiFactory.Create(uri);
|
||||
SimpleApiClient = await apiFactory.Create(uri);
|
||||
|
||||
var isdotcom = HostAddress.IsGitHubDotComUri(uri.ToRepositoryUrl());
|
||||
|
||||
|
@ -139,7 +139,7 @@ namespace GitHub.VisualStudio.Base
|
|||
return origin == RepositoryOrigin.DotCom || origin == RepositoryOrigin.Enterprise;
|
||||
}
|
||||
|
||||
protected bool IsUserAuthenticated()
|
||||
protected async Task<bool> IsUserAuthenticated()
|
||||
{
|
||||
if (SimpleApiClient == null)
|
||||
{
|
||||
|
@ -150,7 +150,7 @@ namespace GitHub.VisualStudio.Base
|
|||
if (uri == null)
|
||||
return false;
|
||||
|
||||
SimpleApiClient = apiFactory.Create(uri);
|
||||
SimpleApiClient = await apiFactory.Create(uri);
|
||||
}
|
||||
|
||||
return SimpleApiClient?.IsAuthenticated() ?? false;
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<OutputPath>..\..\build\$(Configuration)\</OutputPath>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<RunCodeAnalysis>true</RunCodeAnalysis>
|
||||
<CodeAnalysisRuleSet>..\common\GitHubVS.ruleset</CodeAnalysisRuleSet>
|
||||
|
|
|
@ -19,8 +19,11 @@
|
|||
<NuGetPackageImportStamp>
|
||||
</NuGetPackageImportStamp>
|
||||
<TargetFrameworkProfile />
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration_User.cs')">Internal</BuildType>
|
||||
<ApplicationVersion>2.2.0.7</ApplicationVersion>
|
||||
<BuildType Condition="Exists('..\..\script\src\ApiClientConfiguration.cs')">Internal</BuildType>
|
||||
<ApplicationVersion>2.2.1.100</ApplicationVersion>
|
||||
<ApplicationVersion>2.3.0.0</ApplicationVersion>
|
||||
<OutputPath>..\..\build\$(Configuration)\</OutputPath>
|
||||
<VsixType>v3</VsixType>
|
||||
<IsProductComponent>false</IsProductComponent>
|
||||
|
@ -311,8 +314,10 @@
|
|||
<Compile Include="Menus\MenuProvider.cs" />
|
||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
||||
<Compile Include="Helpers\Browser.cs" />
|
||||
<Compile Include="Services\JsonConnectionCache.cs" />
|
||||
<Compile Include="Services\UIProvider.cs" />
|
||||
<Compile Include="Services\UsageTracker.cs" />
|
||||
<Compile Include="Services\LoginManagerDispatcher.cs" />
|
||||
<Compile Include="Services\UsageTrackerDispatcher.cs" />
|
||||
<Compile Include="Settings\Constants.cs" />
|
||||
<Compile Include="Services\ConnectionManager.cs" />
|
||||
|
@ -744,4 +749,4 @@
|
|||
<Target Name="AfterBuild">
|
||||
</Target>
|
||||
-->
|
||||
</Project>
|
||||
</Project>
|
||||
|
|
|
@ -17,6 +17,7 @@ using Task = System.Threading.Tasks.Task;
|
|||
using GitHub.VisualStudio.Menus;
|
||||
using System.ComponentModel.Design;
|
||||
using GitHub.ViewModels;
|
||||
using GitHub.Api;
|
||||
|
||||
namespace GitHub.VisualStudio
|
||||
{
|
||||
|
@ -89,6 +90,7 @@ namespace GitHub.VisualStudio
|
|||
|
||||
[NullGuard.NullGuard(NullGuard.ValidationFlags.None)]
|
||||
[PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)]
|
||||
[ProvideService(typeof(ILoginManager), IsAsyncQueryable = true)]
|
||||
[ProvideService(typeof(IMenuProvider), IsAsyncQueryable = true)]
|
||||
[ProvideService(typeof(IGitHubServiceProvider), IsAsyncQueryable = true)]
|
||||
[ProvideService(typeof(IUsageTracker), IsAsyncQueryable = true)]
|
||||
|
@ -130,6 +132,7 @@ namespace GitHub.VisualStudio
|
|||
{
|
||||
AddService(typeof(IGitHubServiceProvider), CreateService, true);
|
||||
AddService(typeof(IUsageTracker), CreateService, true);
|
||||
AddService(typeof(ILoginManager), CreateService, true);
|
||||
AddService(typeof(IMenuProvider), CreateService, true);
|
||||
AddService(typeof(IUIProvider), CreateService, true);
|
||||
AddService(typeof(IGitHubToolWindowManager), CreateService, true);
|
||||
|
@ -188,6 +191,20 @@ namespace GitHub.VisualStudio
|
|||
await result.Initialize();
|
||||
return result;
|
||||
}
|
||||
else if (serviceType == typeof(ILoginManager))
|
||||
{
|
||||
var serviceProvider = await GetServiceAsync(typeof(IGitHubServiceProvider)) as IGitHubServiceProvider;
|
||||
var loginCache = serviceProvider.GetService<ILoginCache>();
|
||||
var twoFaHandler = serviceProvider.GetService<ITwoFactorChallengeHandler>();
|
||||
|
||||
return new LoginManager(
|
||||
loginCache,
|
||||
twoFaHandler,
|
||||
ApiClientConfiguration.ClientId,
|
||||
ApiClientConfiguration.ClientSecret,
|
||||
ApiClientConfiguration.AuthorizationNote,
|
||||
ApiClientConfiguration.MachineFingerprint);
|
||||
}
|
||||
else if (serviceType == typeof(IMenuProvider))
|
||||
{
|
||||
var sp = await GetServiceAsync(typeof(IGitHubServiceProvider)) as IGitHubServiceProvider;
|
||||
|
|
|
@ -104,7 +104,7 @@ namespace GitHub.VisualStudio
|
|||
if (uri == null)
|
||||
return false;
|
||||
|
||||
SimpleApiClient = ApiFactory.Create(uri);
|
||||
SimpleApiClient = await ApiFactory.Create(uri);
|
||||
|
||||
var isdotcom = HostAddress.IsGitHubDotComUri(uri.ToRepositoryUrl());
|
||||
if (!isdotcom)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using System.Reflection;
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.VisualStudio.Shell;
|
||||
|
||||
|
@ -6,14 +7,15 @@ using Microsoft.VisualStudio.Shell;
|
|||
[assembly: AssemblyDescription("GitHub for Visual Studio VSPackage")]
|
||||
[assembly: Guid("fad77eaa-3fe1-4c4b-88dc-3753b6263cd7")]
|
||||
|
||||
// NOTE: If you provide a codebase for an assembly, codebases must also be provided for all of that
|
||||
// assembly's references. That is unless they're in the GAC or one of Visual Studio's probing
|
||||
// directories (IDE, PublicAssemblies, PrivateAssemblies, etc).
|
||||
[assembly: ProvideBindingRedirection(AssemblyName = "GitHub.UI", CodeBase = @"$PackageFolder$\GitHub.UI.dll",
|
||||
OldVersionLowerBound = "0.0.0.0", OldVersionUpperBound = AssemblyVersionInformation.Version)]
|
||||
[assembly: ProvideBindingRedirection(AssemblyName = "GitHub.VisualStudio.UI", CodeBase = @"$PackageFolder$\GitHub.VisualStudio.UI.dll",
|
||||
OldVersionLowerBound = "0.0.0.0", OldVersionUpperBound = AssemblyVersionInformation.Version)]
|
||||
[assembly: ProvideBindingRedirection(AssemblyName = "GitHub.Exports", CodeBase = @"$PackageFolder$\GitHub.Exports.dll",
|
||||
OldVersionLowerBound = "0.0.0.0", OldVersionUpperBound = AssemblyVersionInformation.Version)]
|
||||
[assembly: ProvideBindingRedirection(AssemblyName = "GitHub.Extensions", CodeBase = @"$PackageFolder$\GitHub.Extensions.dll",
|
||||
OldVersionLowerBound = "0.0.0.0", OldVersionUpperBound = AssemblyVersionInformation.Version)]
|
||||
|
||||
[assembly: ProvideCodeBase(AssemblyName = "GitHub.UI", CodeBase = @"$PackageFolder$\GitHub.UI.dll")]
|
||||
[assembly: ProvideCodeBase(AssemblyName = "GitHub.VisualStudio.UI", CodeBase = @"$PackageFolder$\GitHub.VisualStudio.UI.dll")]
|
||||
[assembly: ProvideCodeBase(AssemblyName = "GitHub.Exports", CodeBase = @"$PackageFolder$\GitHub.Exports.dll")]
|
||||
[assembly: ProvideCodeBase(AssemblyName = "GitHub.Extensions", CodeBase = @"$PackageFolder$\GitHub.Extensions.dll")]
|
||||
[assembly: ProvideCodeBase(AssemblyName = "Octokit", CodeBase = @"$PackageFolder$\Octokit.dll")]
|
||||
[assembly: ProvideCodeBase(AssemblyName = "LibGit2Sharp", CodeBase = @"$PackageFolder$\LibGit2Sharp.dll")]
|
||||
[assembly: ProvideCodeBase(AssemblyName = "Splat", CodeBase = @"$PackageFolder$\Splat.dll")]
|
||||
|
|
|
@ -1,86 +1,44 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel.Composition;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using GitHub.Models;
|
||||
using GitHub.Services;
|
||||
using GitHub.Primitives;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Api;
|
||||
using GitHub.Extensions;
|
||||
using GitHub.Factories;
|
||||
using GitHub.Models;
|
||||
using GitHub.Primitives;
|
||||
using GitHub.Services;
|
||||
|
||||
namespace GitHub.VisualStudio
|
||||
{
|
||||
class CacheData
|
||||
{
|
||||
public IEnumerable<ConnectionCacheItem> connections;
|
||||
}
|
||||
|
||||
class ConnectionCacheItem
|
||||
{
|
||||
public Uri HostUrl { get; set; }
|
||||
public string UserName { get; set; }
|
||||
}
|
||||
|
||||
[Export(typeof(IConnectionManager))]
|
||||
[PartCreationPolicy(CreationPolicy.Shared)]
|
||||
public class ConnectionManager : IConnectionManager
|
||||
{
|
||||
readonly string cachePath;
|
||||
readonly IVSGitServices vsGitServices;
|
||||
const string cacheFile = "ghfvs.connections";
|
||||
readonly IConnectionCache cache;
|
||||
readonly ILoginManager loginManager;
|
||||
readonly IApiClientFactory apiClientFactory;
|
||||
|
||||
public event Func<IConnection, IObservable<IConnection>> DoLogin;
|
||||
|
||||
Func<string, bool> fileExists;
|
||||
Func<string, Encoding, string> readAllText;
|
||||
Action<string, string> writeAllText;
|
||||
Action<string> fileDelete;
|
||||
Func<string, bool> dirExists;
|
||||
Action<string> dirCreate;
|
||||
|
||||
[ImportingConstructor]
|
||||
public ConnectionManager(IProgram program, IVSGitServices vsGitServices)
|
||||
public ConnectionManager(
|
||||
IProgram program,
|
||||
IVSGitServices vsGitServices,
|
||||
IConnectionCache cache,
|
||||
ILoginManager loginManager,
|
||||
IApiClientFactory apiClientFactory)
|
||||
{
|
||||
this.vsGitServices = vsGitServices;
|
||||
fileExists = (path) => System.IO.File.Exists(path);
|
||||
readAllText = (path, encoding) => System.IO.File.ReadAllText(path, encoding);
|
||||
writeAllText = (path, content) => System.IO.File.WriteAllText(path, content);
|
||||
fileDelete = (path) => System.IO.File.Delete(path);
|
||||
dirExists = (path) => System.IO.Directory.Exists(path);
|
||||
dirCreate = (path) => System.IO.Directory.CreateDirectory(path);
|
||||
this.cache = cache;
|
||||
this.loginManager = loginManager;
|
||||
this.apiClientFactory = apiClientFactory;
|
||||
|
||||
Connections = new ObservableCollection<IConnection>();
|
||||
cachePath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
program.ApplicationName,
|
||||
cacheFile);
|
||||
|
||||
LoadConnectionsFromCache();
|
||||
|
||||
Connections.CollectionChanged += RefreshConnections;
|
||||
}
|
||||
|
||||
public ConnectionManager(IProgram program, Rothko.IOperatingSystem os, IVSGitServices vsGitServices)
|
||||
{
|
||||
this.vsGitServices = vsGitServices;
|
||||
fileExists = (path) => os.File.Exists(path);
|
||||
readAllText = (path, encoding) => os.File.ReadAllText(path, encoding);
|
||||
writeAllText = (path, content) => os.File.WriteAllText(path, content);
|
||||
fileDelete = (path) => os.File.Delete(path);
|
||||
dirExists = (path) => os.Directory.Exists(path);
|
||||
dirCreate = (path) => os.Directory.CreateDirectory(path);
|
||||
|
||||
Connections = new ObservableCollection<IConnection>();
|
||||
cachePath = System.IO.Path.Combine(
|
||||
os.Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
program.ApplicationName,
|
||||
cacheFile);
|
||||
|
||||
LoadConnectionsFromCache();
|
||||
|
||||
Connections.CollectionChanged += RefreshConnections;
|
||||
LoadConnectionsFromCache().Forget();
|
||||
}
|
||||
|
||||
public IConnection CreateConnection(HostAddress address, string username)
|
||||
|
@ -158,71 +116,43 @@ namespace GitHub.VisualStudio
|
|||
c.Dispose();
|
||||
}
|
||||
}
|
||||
SaveConnectionsToCache();
|
||||
|
||||
SaveConnectionsToCache().Forget();
|
||||
}
|
||||
|
||||
void LoadConnectionsFromCache()
|
||||
async Task LoadConnectionsFromCache()
|
||||
{
|
||||
EnsureCachePath();
|
||||
|
||||
if (!fileExists(cachePath))
|
||||
return;
|
||||
|
||||
string data = readAllText(cachePath, Encoding.UTF8);
|
||||
|
||||
CacheData cacheData;
|
||||
try
|
||||
foreach (var c in await cache.Load())
|
||||
{
|
||||
cacheData = SimpleJson.DeserializeObject<CacheData>(data);
|
||||
}
|
||||
catch
|
||||
{
|
||||
cacheData = null;
|
||||
}
|
||||
var client = await apiClientFactory.CreateGitHubClient(c.HostAddress);
|
||||
var addConnection = true;
|
||||
|
||||
if (cacheData == null || cacheData.connections == null)
|
||||
{
|
||||
// cache is corrupt, remove
|
||||
fileDelete(cachePath);
|
||||
return;
|
||||
}
|
||||
|
||||
cacheData.connections.ForEach(c =>
|
||||
{
|
||||
if (c.HostUrl != null)
|
||||
AddConnection(c.HostUrl, c.UserName);
|
||||
});
|
||||
}
|
||||
|
||||
void SaveConnectionsToCache()
|
||||
{
|
||||
EnsureCachePath();
|
||||
|
||||
var cache = new CacheData();
|
||||
cache.connections = Connections.Select(conn =>
|
||||
new ConnectionCacheItem
|
||||
try
|
||||
{
|
||||
HostUrl = conn.HostAddress.WebUri,
|
||||
UserName = conn.Username,
|
||||
});
|
||||
try
|
||||
{
|
||||
string data = SimpleJson.SerializeObject(cache);
|
||||
writeAllText(cachePath, data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.Fail(ex.ToString());
|
||||
await loginManager.LoginFromCache(c.HostAddress, client);
|
||||
}
|
||||
catch (Octokit.ApiException e)
|
||||
{
|
||||
addConnection = false;
|
||||
VsOutputLogger.WriteLine("Cached credentials for connection {0} were invalid: {1}", c.HostAddress, e);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Add the connection in this case - could be that there's no internet connection.
|
||||
}
|
||||
|
||||
if (addConnection)
|
||||
{
|
||||
AddConnection(c.HostAddress, c.UserName);
|
||||
}
|
||||
}
|
||||
|
||||
Connections.CollectionChanged += RefreshConnections;
|
||||
}
|
||||
|
||||
void EnsureCachePath()
|
||||
async Task SaveConnectionsToCache()
|
||||
{
|
||||
if (fileExists(cachePath))
|
||||
return;
|
||||
var di = System.IO.Path.GetDirectoryName(cachePath);
|
||||
if (!dirExists(di))
|
||||
dirCreate(di);
|
||||
await cache.Save(Connections.Select(x => new ConnectionDetails(x.HostAddress, x.Username)));
|
||||
}
|
||||
|
||||
public ObservableCollection<IConnection> Connections { get; private set; }
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.Composition;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Models;
|
||||
using GitHub.Primitives;
|
||||
using GitHub.Services;
|
||||
using Rothko;
|
||||
|
||||
namespace GitHub.VisualStudio
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads and saves the configured connections from/to a .json file.
|
||||
/// </summary>
|
||||
[Export(typeof(IConnectionCache))]
|
||||
public class JsonConnectionCache : IConnectionCache
|
||||
{
|
||||
const string DefaultCacheFile = "ghfvs.connections";
|
||||
readonly string cachePath;
|
||||
readonly IOperatingSystem os;
|
||||
|
||||
[ImportingConstructor]
|
||||
public JsonConnectionCache(IProgram program, IOperatingSystem os)
|
||||
: this(program, os, DefaultCacheFile)
|
||||
{
|
||||
}
|
||||
|
||||
public JsonConnectionCache(IProgram program, IOperatingSystem os, string cacheFile)
|
||||
{
|
||||
this.os = os;
|
||||
cachePath = Path.Combine(
|
||||
os.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),
|
||||
program.ApplicationName,
|
||||
cacheFile);
|
||||
}
|
||||
|
||||
public Task<IEnumerable<ConnectionDetails>> Load()
|
||||
{
|
||||
if (os.File.Exists(cachePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
// TODO: Need a ReadAllTextAsync method here.
|
||||
var data = os.File.ReadAllText(cachePath, Encoding.UTF8);
|
||||
var result = SimpleJson.DeserializeObject<CacheData>(data);
|
||||
return Task.FromResult(result.connections.Select(FromCache));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
try
|
||||
{
|
||||
os.File.Delete(cachePath);
|
||||
}
|
||||
catch { }
|
||||
|
||||
VsOutputLogger.WriteLine("Failed to read connection cache from {0}: {1}", cachePath, e);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(Enumerable.Empty<ConnectionDetails>());
|
||||
}
|
||||
|
||||
public Task Save(IEnumerable<ConnectionDetails> connections)
|
||||
{
|
||||
var data = SimpleJson.SerializeObject(new CacheData
|
||||
{
|
||||
connections = connections.Select(ToCache).ToList(),
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
os.File.WriteAllText(cachePath, data);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
VsOutputLogger.WriteLine("Failed to write connection cache to {0}: {1}", cachePath, e);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
static ConnectionDetails FromCache(ConnectionCacheItem c)
|
||||
{
|
||||
return new ConnectionDetails(HostAddress.Create(c.HostUrl), c.UserName);
|
||||
}
|
||||
|
||||
static ConnectionCacheItem ToCache(ConnectionDetails c)
|
||||
{
|
||||
return new ConnectionCacheItem
|
||||
{
|
||||
HostUrl = c.HostAddress.WebUri,
|
||||
UserName = c.UserName,
|
||||
};
|
||||
}
|
||||
|
||||
class CacheData
|
||||
{
|
||||
public IEnumerable<ConnectionCacheItem> connections { get; set; }
|
||||
}
|
||||
|
||||
class ConnectionCacheItem
|
||||
{
|
||||
public Uri HostUrl { get; set; }
|
||||
public string UserName { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
using System;
|
||||
using System.ComponentModel.Composition;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Api;
|
||||
using GitHub.Primitives;
|
||||
using Microsoft.VisualStudio.Shell;
|
||||
using Octokit;
|
||||
using Task = System.Threading.Tasks.Task;
|
||||
|
||||
namespace GitHub.Services
|
||||
{
|
||||
[Export(typeof(ILoginManager))]
|
||||
[PartCreationPolicy(CreationPolicy.NonShared)]
|
||||
public class LoginManagerDispatcher : ILoginManager
|
||||
{
|
||||
readonly ILoginManager inner;
|
||||
|
||||
[ImportingConstructor]
|
||||
public LoginManagerDispatcher([Import(typeof(SVsServiceProvider))] IServiceProvider serviceProvider)
|
||||
{
|
||||
inner = serviceProvider.GetService(typeof(ILoginManager)) as ILoginManager;
|
||||
}
|
||||
|
||||
public Task<User> Login(HostAddress hostAddress, IGitHubClient client, string userName, string password)
|
||||
{
|
||||
return inner.Login(hostAddress, client, userName, password);
|
||||
}
|
||||
|
||||
public Task<User> LoginFromCache(HostAddress hostAddress, IGitHubClient client)
|
||||
{
|
||||
return inner.LoginFromCache(hostAddress, client);
|
||||
}
|
||||
|
||||
public Task Logout(HostAddress hostAddress, IGitHubClient client)
|
||||
{
|
||||
return inner.Logout(hostAddress, client);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -11,21 +11,21 @@ using Task = System.Threading.Tasks.Task;
|
|||
using GitHub.Extensions;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Helpers;
|
||||
using System.Threading;
|
||||
|
||||
namespace GitHub.Services
|
||||
{
|
||||
public class UsageTracker : IUsageTracker
|
||||
public sealed class UsageTracker : IUsageTracker, IDisposable
|
||||
{
|
||||
const string StoreFileName = "ghfvs.usage";
|
||||
static readonly Calendar cal = CultureInfo.InvariantCulture.Calendar;
|
||||
|
||||
readonly IGitHubServiceProvider gitHubServiceProvider;
|
||||
readonly DispatcherTimer timer;
|
||||
|
||||
IMetricsService client;
|
||||
IConnectionManager connectionManager;
|
||||
IPackageSettings userSettings;
|
||||
IVSServices vsservices;
|
||||
Timer timer;
|
||||
string storePath;
|
||||
bool firstRun = true;
|
||||
|
||||
|
@ -61,13 +61,16 @@ namespace GitHub.Services
|
|||
};
|
||||
dirCreate = (path) => System.IO.Directory.CreateDirectory(path);
|
||||
|
||||
this.timer = new DispatcherTimer(
|
||||
TimeSpan.FromMinutes(3),
|
||||
DispatcherPriority.Background,
|
||||
this.timer = new Timer(
|
||||
TimerTick,
|
||||
ThreadingHelper.MainThreadDispatcher);
|
||||
null,
|
||||
TimeSpan.FromMinutes(3),
|
||||
TimeSpan.FromHours(8));
|
||||
}
|
||||
|
||||
RunTimer();
|
||||
public void Dispose()
|
||||
{
|
||||
timer?.Dispose();
|
||||
}
|
||||
|
||||
public async Task IncrementLaunchCount()
|
||||
|
@ -192,6 +195,13 @@ namespace GitHub.Services
|
|||
SaveUsage(usage);
|
||||
}
|
||||
|
||||
public async Task IncrementPullRequestOpened()
|
||||
{
|
||||
var usage = await LoadUsage();
|
||||
++usage.Model.NumberOfPullRequestsOpened;
|
||||
SaveUsage(usage);
|
||||
}
|
||||
|
||||
async Task Initialize()
|
||||
{
|
||||
// The services needed by the usage tracker are loaded when they are first needed to
|
||||
|
@ -244,14 +254,7 @@ namespace GitHub.Services
|
|||
writeAllText(storePath, json, Encoding.UTF8);
|
||||
}
|
||||
|
||||
void RunTimer()
|
||||
{
|
||||
// The timer first ticks after 3 minutes to allow things to settle down after startup.
|
||||
// This will be changed to 8 hours after the first tick by the TimerTick method.
|
||||
timer.Start();
|
||||
}
|
||||
|
||||
void TimerTick(object sender, EventArgs e)
|
||||
void TimerTick(object state)
|
||||
{
|
||||
TimerTick()
|
||||
.Catch(ex =>
|
||||
|
@ -268,13 +271,13 @@ namespace GitHub.Services
|
|||
if (firstRun)
|
||||
{
|
||||
await IncrementLaunchCount();
|
||||
timer.Interval = TimeSpan.FromHours(8);
|
||||
firstRun = false;
|
||||
}
|
||||
|
||||
if (client == null || !userSettings.CollectMetrics)
|
||||
{
|
||||
timer.Stop();
|
||||
timer.Dispose();
|
||||
timer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -350,6 +353,9 @@ namespace GitHub.Services
|
|||
usage.NumberOfForkPullRequestsCheckedOut = 0;
|
||||
usage.NumberOfForkPullRequestPulls = 0;
|
||||
usage.NumberOfForkPullRequestPushes = 0;
|
||||
usage.NumberOfGitHubPaneHelpClicks = 0;
|
||||
usage.NumberOfWelcomeTrainingClicks = 0;
|
||||
usage.NumberOfWelcomeDocsClicks = 0;
|
||||
|
||||
if (weekly)
|
||||
usage.NumberOfStartupsWeek = 0;
|
||||
|
|
|
@ -26,6 +26,7 @@ namespace GitHub.Services
|
|||
public Task IncrementOpenInGitHubCount() => inner.IncrementOpenInGitHubCount();
|
||||
public Task IncrementPublishCount() => inner.IncrementPublishCount();
|
||||
public Task IncrementUpstreamPullRequestCount() => inner.IncrementUpstreamPullRequestCount();
|
||||
public Task IncrementPullRequestOpened() => inner.IncrementPullRequestOpened();
|
||||
public Task IncrementPullRequestCheckOutCount(bool fork) => inner.IncrementPullRequestCheckOutCount(fork);
|
||||
public Task IncrementPullRequestPullCount(bool fork) => inner.IncrementPullRequestPullCount(fork);
|
||||
public Task IncrementPullRequestPushCount(bool fork) => inner.IncrementPullRequestPushCount(fork);
|
||||
|
|
|
@ -55,7 +55,6 @@
|
|||
x:Name="hostTabControl"
|
||||
Margin="30,0"
|
||||
Style="{StaticResource LightModalViewTabControl}"
|
||||
SelectionChanged="hostTabControl_SelectionChanged"
|
||||
FocusManager.IsFocusScope="True"
|
||||
FocusVisualStyle="{x:Null}"
|
||||
helpers:AccessKeysManagerScoping.IsEnabled="True"
|
||||
|
|
|
@ -22,8 +22,6 @@ namespace GitHub.VisualStudio.UI.Views.Controls
|
|||
[PartCreationPolicy(CreationPolicy.NonShared)]
|
||||
public partial class LoginControl : GenericLoginControl
|
||||
{
|
||||
IDisposable errorHandler;
|
||||
|
||||
public LoginControl()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
@ -33,8 +31,6 @@ namespace GitHub.VisualStudio.UI.Views.Controls
|
|||
SetupDotComBindings(d);
|
||||
SetupEnterpriseBindings(d);
|
||||
SetupSelectedAndVisibleTabBindings(d);
|
||||
|
||||
d(Disposable.Create(() => errorHandler.Dispose()));
|
||||
});
|
||||
|
||||
IsVisibleChanged += (s, e) =>
|
||||
|
@ -57,6 +53,7 @@ namespace GitHub.VisualStudio.UI.Views.Controls
|
|||
d(this.OneWayBind(ViewModel, vm => vm.GitHubLogin.Login, v => v.dotComLogInButton.Command));
|
||||
d(this.OneWayBind(ViewModel, vm => vm.GitHubLogin.IsLoggingIn, v => v.dotComLogInButton.ShowSpinner));
|
||||
d(this.OneWayBind(ViewModel, vm => vm.GitHubLogin.NavigatePricing, v => v.pricingLink.Command));
|
||||
d(this.OneWayBind(ViewModel, vm => vm.GitHubLogin.Error, v => v.dotComErrorMessage.UserError));
|
||||
}
|
||||
|
||||
void SetupEnterpriseBindings(Action<IDisposable> d)
|
||||
|
@ -75,6 +72,7 @@ namespace GitHub.VisualStudio.UI.Views.Controls
|
|||
d(this.OneWayBind(ViewModel, vm => vm.EnterpriseLogin.Login, v => v.enterpriseLogInButton.Command));
|
||||
d(this.OneWayBind(ViewModel, vm => vm.EnterpriseLogin.IsLoggingIn, v => v.enterpriseLogInButton.ShowSpinner));
|
||||
d(this.OneWayBind(ViewModel, vm => vm.EnterpriseLogin.NavigateLearnMore, v => v.learnMoreLink.Command));
|
||||
d(this.OneWayBind(ViewModel, vm => vm.EnterpriseLogin.Error, v => v.enterpriseErrorMessage.UserError));
|
||||
}
|
||||
|
||||
void SetupSelectedAndVisibleTabBindings(Action<IDisposable> d)
|
||||
|
@ -97,25 +95,5 @@ namespace GitHub.VisualStudio.UI.Views.Controls
|
|||
.Where(x => x == true)
|
||||
.BindTo(this, v => v.enterpriseTab.IsSelected));
|
||||
}
|
||||
|
||||
void hostTabControl_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
|
||||
{
|
||||
// This is a bit ugly but it's the simplest way I could think of dealing with it: there can only
|
||||
// be one UserErrorMessages control active at any time and we need one for each tab. Register/unregister
|
||||
// them here when the tab is changed.
|
||||
var clearErrorWhen = Observable.Return(false);
|
||||
|
||||
errorHandler?.Dispose();
|
||||
|
||||
switch (hostTabControl.SelectedIndex)
|
||||
{
|
||||
case 0:
|
||||
errorHandler = dotComErrorMessage.RegisterHandler<UserError>(clearErrorWhen);
|
||||
break;
|
||||
case 1:
|
||||
errorHandler = enterpriseErrorMessage.RegisterHandler<UserError>(clearErrorWhen);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -155,15 +155,6 @@
|
|||
IsEnabled="{Binding FilterTextIsEnabled, Mode=OneWay}"
|
||||
AutomationProperties.AutomationId="{x:Static automation:AutomationIDs.SearchRepositoryTextBox}" />
|
||||
|
||||
<ui:GitHubProgressBar x:Name="loadingProgressBar"
|
||||
Margin="0"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
DockPanel.Dock="Top"
|
||||
Foreground="{DynamicResource GitHubAccentBrush}"
|
||||
IsIndeterminate="True"
|
||||
Style="{DynamicResource GitHubProgressBar}"
|
||||
Visibility="{Binding IsBusy, Mode=OneWay, Converter={ui:BooleanToVisibilityConverter}}" />
|
||||
|
||||
<StackPanel x:Name="loadingFailedPanel"
|
||||
Margin="0,4,0,4"
|
||||
HorizontalAlignment="Center"
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
d:DesignWidth="414"
|
||||
d:DesignHeight="440"
|
||||
Style="{DynamicResource DialogUserControl}"
|
||||
ShowBusyState="False"
|
||||
AutomationProperties.AutomationId="{x:Static automation:AutomationIDs.TwoFactorAuthenticationCustom}" >
|
||||
|
||||
<Control.Resources>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011" xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011">
|
||||
<Metadata>
|
||||
<Identity Id="c3d3dc68-c977-411f-b3e8-03b0dccf7dfc" Version="2.2.1.100" Language="en-US" Publisher="GitHub, Inc" />
|
||||
<Identity Id="c3d3dc68-c977-411f-b3e8-03b0dccf7dfc" Version="2.3.0.0" Language="en-US" Publisher="GitHub, Inc" />
|
||||
<DisplayName>GitHub Extension for Visual Studio</DisplayName>
|
||||
<Description xml:space="preserve">A Visual Studio Extension that brings the GitHub Flow into Visual Studio.</Description>
|
||||
<PackageId>GitHub.VisualStudio</PackageId>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Include>
|
||||
<?define VersionNumber="2.2.1.100" ?>
|
||||
<?define VersionNumber="2.3.0.0" ?>
|
||||
</Include>
|
||||
|
|
|
@ -0,0 +1,272 @@
|
|||
using System;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Api;
|
||||
using GitHub.Primitives;
|
||||
using NSubstitute;
|
||||
using Octokit;
|
||||
using Xunit;
|
||||
|
||||
public class LoginManagerTests
|
||||
{
|
||||
static readonly HostAddress host = HostAddress.GitHubDotComHostAddress;
|
||||
static readonly HostAddress enterprise = HostAddress.Create("https://enterprise.hub");
|
||||
|
||||
public class TheLoginMethod
|
||||
{
|
||||
[Fact]
|
||||
public async Task LoginTokenIsSavedToCache()
|
||||
{
|
||||
var client = Substitute.For<IGitHubClient>();
|
||||
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
|
||||
.Returns(new ApplicationAuthorization("123abc"));
|
||||
|
||||
var loginCache = Substitute.For<ILoginCache>();
|
||||
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
|
||||
|
||||
var target = new LoginManager(loginCache, tfa, "id", "secret");
|
||||
await target.Login(host, client, "foo", "bar");
|
||||
|
||||
await loginCache.Received().SaveLogin("foo", "123abc", host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoggedInUserIsReturned()
|
||||
{
|
||||
var client = Substitute.For<IGitHubClient>();
|
||||
var user = new User();
|
||||
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
|
||||
.Returns(new ApplicationAuthorization("123abc"));
|
||||
client.User.Current().Returns(user);
|
||||
|
||||
var loginCache = Substitute.For<ILoginCache>();
|
||||
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
|
||||
|
||||
var target = new LoginManager(loginCache, tfa, "id", "secret");
|
||||
var result = await target.Login(host, client, "foo", "bar");
|
||||
|
||||
Assert.Same(user, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletesExistingAuthenticationIfNullTokenReturned()
|
||||
{
|
||||
// If GetOrCreateApplicationAuthentication is called and a matching token already exists,
|
||||
// the returned token will be null because it is assumed that the token will be stored
|
||||
// locally. In this case, the existing token should be first deleted.
|
||||
var client = Substitute.For<IGitHubClient>();
|
||||
var user = new User();
|
||||
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
|
||||
.Returns(
|
||||
new ApplicationAuthorization(string.Empty),
|
||||
new ApplicationAuthorization("123abc"));
|
||||
client.User.Current().Returns(user);
|
||||
|
||||
var loginCache = Substitute.For<ILoginCache>();
|
||||
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
|
||||
|
||||
var target = new LoginManager(loginCache, tfa, "id", "secret");
|
||||
var result = await target.Login(host, client, "foo", "bar");
|
||||
|
||||
await client.Authorization.Received(2).GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>());
|
||||
await client.Authorization.Received(1).Delete(0);
|
||||
await loginCache.Received().SaveLogin("foo", "123abc", host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TwoFactorExceptionIsPassedToHandler()
|
||||
{
|
||||
var client = Substitute.For<IGitHubClient>();
|
||||
var exception = new TwoFactorChallengeFailedException();
|
||||
|
||||
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
|
||||
.Returns<ApplicationAuthorization>(_ => { throw exception; });
|
||||
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>(), "123456")
|
||||
.Returns(new ApplicationAuthorization("123abc"));
|
||||
|
||||
var loginCache = Substitute.For<ILoginCache>();
|
||||
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
|
||||
tfa.HandleTwoFactorException(exception).Returns(new TwoFactorChallengeResult("123456"));
|
||||
|
||||
var target = new LoginManager(loginCache, tfa, "id", "secret");
|
||||
await target.Login(host, client, "foo", "bar");
|
||||
|
||||
await client.Authorization.Received().GetOrCreateApplicationAuthentication(
|
||||
"id",
|
||||
"secret",
|
||||
Arg.Any<NewAuthorization>(),
|
||||
"123456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed2FACodeResultsInRetry()
|
||||
{
|
||||
var client = Substitute.For<IGitHubClient>();
|
||||
var exception = new TwoFactorChallengeFailedException();
|
||||
|
||||
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
|
||||
.Returns<ApplicationAuthorization>(_ => { throw exception; });
|
||||
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>(), "111111")
|
||||
.Returns<ApplicationAuthorization>(_ => { throw exception; });
|
||||
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>(), "123456")
|
||||
.Returns(new ApplicationAuthorization("123abc"));
|
||||
|
||||
var loginCache = Substitute.For<ILoginCache>();
|
||||
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
|
||||
tfa.HandleTwoFactorException(exception).Returns(
|
||||
new TwoFactorChallengeResult("111111"),
|
||||
new TwoFactorChallengeResult("123456"));
|
||||
|
||||
var target = new LoginManager(loginCache, tfa, "id", "secret");
|
||||
await target.Login(host, client, "foo", "bar");
|
||||
|
||||
await client.Authorization.Received(1).GetOrCreateApplicationAuthentication(
|
||||
"id",
|
||||
"secret",
|
||||
Arg.Any<NewAuthorization>(),
|
||||
"111111");
|
||||
await client.Authorization.Received(1).GetOrCreateApplicationAuthentication(
|
||||
"id",
|
||||
"secret",
|
||||
Arg.Any<NewAuthorization>(),
|
||||
"123456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandlerNotifiedOfExceptionIn2FAChallengeResponse()
|
||||
{
|
||||
var client = Substitute.For<IGitHubClient>();
|
||||
var twoFaException = new TwoFactorChallengeFailedException();
|
||||
var forbiddenResponse = Substitute.For<IResponse>();
|
||||
forbiddenResponse.StatusCode.Returns(HttpStatusCode.Forbidden);
|
||||
var loginAttemptsException = new LoginAttemptsExceededException(forbiddenResponse);
|
||||
|
||||
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
|
||||
.Returns<ApplicationAuthorization>(_ => { throw twoFaException; });
|
||||
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>(), "111111")
|
||||
.Returns<ApplicationAuthorization>(_ => { throw loginAttemptsException; });
|
||||
|
||||
var loginCache = Substitute.For<ILoginCache>();
|
||||
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
|
||||
tfa.HandleTwoFactorException(twoFaException).Returns(
|
||||
new TwoFactorChallengeResult("111111"),
|
||||
new TwoFactorChallengeResult("123456"));
|
||||
|
||||
var target = new LoginManager(loginCache, tfa, "id", "secret");
|
||||
Assert.ThrowsAsync<LoginAttemptsExceededException>(async () => await target.Login(host, client, "foo", "bar"));
|
||||
|
||||
await client.Authorization.Received(1).GetOrCreateApplicationAuthentication(
|
||||
"id",
|
||||
"secret",
|
||||
Arg.Any<NewAuthorization>(),
|
||||
"111111");
|
||||
tfa.Received(1).ChallengeFailed(loginAttemptsException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestResendCodeResultsInRetryingLogin()
|
||||
{
|
||||
var client = Substitute.For<IGitHubClient>();
|
||||
var exception = new TwoFactorChallengeFailedException();
|
||||
var user = new User();
|
||||
|
||||
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
|
||||
.Returns<ApplicationAuthorization>(_ => { throw exception; });
|
||||
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>(), "123456")
|
||||
.Returns(new ApplicationAuthorization("456def"));
|
||||
client.User.Current().Returns(user);
|
||||
|
||||
var loginCache = Substitute.For<ILoginCache>();
|
||||
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
|
||||
tfa.HandleTwoFactorException(exception).Returns(
|
||||
TwoFactorChallengeResult.RequestResendCode,
|
||||
new TwoFactorChallengeResult("123456"));
|
||||
|
||||
var target = new LoginManager(loginCache, tfa, "id", "secret");
|
||||
await target.Login(host, client, "foo", "bar");
|
||||
|
||||
await client.Authorization.Received(2).GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UsesUsernameAndPasswordInsteadOfAuthorizationTokenWhenEnterpriseAndAPIReturns404()
|
||||
{
|
||||
var client = Substitute.For<IGitHubClient>();
|
||||
var user = new User();
|
||||
|
||||
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
|
||||
.Returns<ApplicationAuthorization>(_ =>
|
||||
{
|
||||
throw new NotFoundException("Not there", HttpStatusCode.NotFound);
|
||||
});
|
||||
client.User.Current().Returns(user);
|
||||
|
||||
var loginCache = Substitute.For<ILoginCache>();
|
||||
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
|
||||
|
||||
var target = new LoginManager(loginCache, tfa, "id", "secret");
|
||||
await target.Login(enterprise, client, "foo", "bar");
|
||||
|
||||
await loginCache.Received().SaveLogin("foo", "bar", enterprise);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ErasesLoginWhenUnauthorized()
|
||||
{
|
||||
var client = Substitute.For<IGitHubClient>();
|
||||
var user = new User();
|
||||
|
||||
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
|
||||
.Returns<ApplicationAuthorization>(_ => { throw new AuthorizationException(); });
|
||||
|
||||
var loginCache = Substitute.For<ILoginCache>();
|
||||
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
|
||||
|
||||
var target = new LoginManager(loginCache, tfa, "id", "secret");
|
||||
await Assert.ThrowsAsync<AuthorizationException>(async () => await target.Login(enterprise, client, "foo", "bar"));
|
||||
|
||||
await loginCache.Received().EraseLogin(enterprise);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ErasesLoginWhenNonOctokitExceptionThrown()
|
||||
{
|
||||
var client = Substitute.For<IGitHubClient>();
|
||||
var user = new User();
|
||||
|
||||
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
|
||||
.Returns<ApplicationAuthorization>(_ => { throw new InvalidOperationException(); });
|
||||
|
||||
var loginCache = Substitute.For<ILoginCache>();
|
||||
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
|
||||
|
||||
var target = new LoginManager(loginCache, tfa, "id", "secret");
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => await target.Login(host, client, "foo", "bar"));
|
||||
|
||||
await loginCache.Received().EraseLogin(host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ErasesLoginWhenNonOctokitExceptionThrownIn2FA()
|
||||
{
|
||||
var client = Substitute.For<IGitHubClient>();
|
||||
var user = new User();
|
||||
var exception = new TwoFactorChallengeFailedException();
|
||||
|
||||
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>())
|
||||
.Returns<ApplicationAuthorization>(_ => { throw exception; });
|
||||
client.Authorization.GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>(), "123456")
|
||||
.Returns<ApplicationAuthorization>(_ => { throw new InvalidOperationException(); });
|
||||
client.User.Current().Returns(user);
|
||||
|
||||
var loginCache = Substitute.For<ILoginCache>();
|
||||
var tfa = Substitute.For<ITwoFactorChallengeHandler>();
|
||||
tfa.HandleTwoFactorException(exception).Returns(new TwoFactorChallengeResult("123456"));
|
||||
|
||||
var target = new LoginManager(loginCache, tfa, "id", "secret");
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => await target.Login(host, client, "foo", "bar"));
|
||||
|
||||
await loginCache.Received().EraseLogin(host);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Api;
|
||||
using GitHub.Primitives;
|
||||
using GitHub.Services;
|
||||
|
@ -11,7 +12,7 @@ public class SimpleApiClientFactoryTests
|
|||
public class TheCreateMethod
|
||||
{
|
||||
[Fact]
|
||||
public void CreatesNewInstanceOfSimpleApiClient()
|
||||
public async Task CreatesNewInstanceOfSimpleApiClient()
|
||||
{
|
||||
const string url = "https://github.com/github/CreatesNewInstanceOfSimpleApiClient";
|
||||
var program = new Program();
|
||||
|
@ -22,18 +23,18 @@ public class SimpleApiClientFactoryTests
|
|||
new Lazy<IEnterpriseProbeTask>(() => enterpriseProbe),
|
||||
new Lazy<IWikiProbe>(() => wikiProbe));
|
||||
|
||||
var client = factory.Create(url);
|
||||
var client = await factory.Create(url);
|
||||
|
||||
Assert.Equal(url, client.OriginalUrl);
|
||||
Assert.Equal(HostAddress.GitHubDotComHostAddress, client.HostAddress);
|
||||
Assert.Same(client, factory.Create(url)); // Tests caching.
|
||||
Assert.Same(client, await factory.Create(url)); // Tests caching.
|
||||
}
|
||||
}
|
||||
|
||||
public class TheClearFromCacheMethod
|
||||
{
|
||||
[Fact]
|
||||
public void RemovesClientFromCache()
|
||||
public async Task RemovesClientFromCache()
|
||||
{
|
||||
const string url = "https://github.com/github/RemovesClientFromCache";
|
||||
var program = new Program();
|
||||
|
@ -44,7 +45,7 @@ public class SimpleApiClientFactoryTests
|
|||
new Lazy<IEnterpriseProbeTask>(() => enterpriseProbe),
|
||||
new Lazy<IWikiProbe>(() => wikiProbe));
|
||||
|
||||
var client = factory.Create(url);
|
||||
var client = await factory.Create(url);
|
||||
factory.ClearFromCache(client);
|
||||
|
||||
Assert.NotSame(client, factory.Create(url));
|
||||
|
|
|
@ -30,9 +30,11 @@ public class RepositoryHostTests
|
|||
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("baymax")));
|
||||
var hostCache = new InMemoryBlobCache();
|
||||
var modelService = new ModelService(apiClient, hostCache, Substitute.For<IAvatarProvider>());
|
||||
var loginManager = Substitute.For<ILoginManager>();
|
||||
loginManager.Login(HostAddress.GitHubDotComHostAddress, Arg.Any<IGitHubClient>(), "baymax", "aPassword").Returns(CreateUserAndScopes("baymax").User);
|
||||
var loginCache = new TestLoginCache();
|
||||
var usage = Substitute.For<IUsageTracker>();
|
||||
var host = new RepositoryHost(apiClient, modelService, loginCache, Substitute.For<ITwoFactorChallengeHandler>(), usage);
|
||||
var host = new RepositoryHost(apiClient, modelService, loginManager, loginCache, usage);
|
||||
|
||||
var result = await host.LogIn("baymax", "aPassword");
|
||||
|
||||
|
@ -40,10 +42,28 @@ public class RepositoryHostTests
|
|||
var user = await hostCache.GetObject<AccountCacheItem>("user");
|
||||
Assert.NotNull(user);
|
||||
Assert.Equal("baymax", user.Login);
|
||||
var loginInfo = await loginCache.GetLoginAsync(HostAddress.GitHubDotComHostAddress);
|
||||
Assert.Equal("baymax", loginInfo.UserName);
|
||||
Assert.Equal("S3CR3TS", loginInfo.Password);
|
||||
Assert.True(host.IsLoggedIn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncrementsLoginCount()
|
||||
{
|
||||
var apiClient = Substitute.For<IApiClient>();
|
||||
apiClient.HostAddress.Returns(HostAddress.GitHubDotComHostAddress);
|
||||
apiClient.GetOrCreateApplicationAuthenticationCode(
|
||||
Args.TwoFactorChallengCallback, Args.String, Args.Boolean)
|
||||
.Returns(Observable.Return(new ApplicationAuthorization("S3CR3TS")));
|
||||
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("baymax")));
|
||||
var hostCache = new InMemoryBlobCache();
|
||||
var modelService = Substitute.For<IModelService>();
|
||||
var loginManager = Substitute.For<ILoginManager>();
|
||||
loginManager.Login(HostAddress.GitHubDotComHostAddress, Arg.Any<IGitHubClient>(), "baymax", "aPassword").Returns(CreateUserAndScopes("baymax").User);
|
||||
var loginCache = new TestLoginCache();
|
||||
var usage = Substitute.For<IUsageTracker>();
|
||||
var host = new RepositoryHost(apiClient, modelService, loginManager, loginCache, usage);
|
||||
|
||||
var result = await host.LogIn("baymax", "aPassword");
|
||||
|
||||
await usage.Received().IncrementLoginCount();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
@ -51,178 +71,61 @@ public class RepositoryHostTests
|
|||
{
|
||||
var apiClient = Substitute.For<IApiClient>();
|
||||
apiClient.HostAddress.Returns(HostAddress.GitHubDotComHostAddress);
|
||||
apiClient.GetOrCreateApplicationAuthenticationCode(
|
||||
Args.TwoFactorChallengCallback, Args.String, Args.Boolean)
|
||||
.Returns(Observable.Throw<ApplicationAuthorization>(new NotFoundException("", HttpStatusCode.BadGateway)));
|
||||
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("jiminy")));
|
||||
var hostCache = new InMemoryBlobCache();
|
||||
var modelService = new ModelService(apiClient, hostCache, Substitute.For<IAvatarProvider>());
|
||||
var loginManager = Substitute.For<ILoginManager>();
|
||||
loginManager.Login(HostAddress.GitHubDotComHostAddress, Arg.Any<IGitHubClient>(), "jiminy", "cricket")
|
||||
.Returns<User>(_ => { throw new NotFoundException("", HttpStatusCode.BadGateway); });
|
||||
var loginCache = new TestLoginCache();
|
||||
var usage = Substitute.For<IUsageTracker>();
|
||||
var host = new RepositoryHost(apiClient, modelService, loginCache, Substitute.For<ITwoFactorChallengeHandler>(), usage);
|
||||
var host = new RepositoryHost(apiClient, modelService, loginManager, loginCache, usage);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(async () => await host.LogIn("jiminy", "cricket"));
|
||||
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(
|
||||
async () => await hostCache.GetObject<AccountCacheItem>("user"));
|
||||
var loginInfo = await loginCache.GetLoginAsync(HostAddress.GitHubDotComHostAddress);
|
||||
Assert.Equal("jiminy", loginInfo.UserName);
|
||||
Assert.Equal("cricket", loginInfo.Password);
|
||||
Assert.False(host.IsLoggedIn);
|
||||
}
|
||||
}
|
||||
|
||||
public class TheLoginFromCacheMethod : TestBaseClass
|
||||
{
|
||||
[Fact]
|
||||
public async Task UsesUsernameAndPasswordInsteadOfAuthorizationTokenWhenEnterpriseAndAPIReturns404()
|
||||
{
|
||||
var enterpriseHostAddress = HostAddress.Create("https://enterprise.example.com/");
|
||||
var apiClient = Substitute.For<IApiClient>();
|
||||
apiClient.HostAddress.Returns(enterpriseHostAddress);
|
||||
// Throw a 404 on the first try with the new scopes
|
||||
apiClient.GetOrCreateApplicationAuthenticationCode(Args.TwoFactorChallengCallback, null, false, true)
|
||||
.Returns(Observable.Throw<ApplicationAuthorization>(new NotFoundException("Not there", HttpStatusCode.NotFound)));
|
||||
// Throw a 404 on the retry with the old scopes:
|
||||
apiClient.GetOrCreateApplicationAuthenticationCode(Args.TwoFactorChallengCallback, null, true, false)
|
||||
.Returns(Observable.Throw<ApplicationAuthorization>(new NotFoundException("Also not there", HttpStatusCode.NotFound)));
|
||||
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("Cthulu")));
|
||||
var hostCache = new InMemoryBlobCache();
|
||||
var modelService = new ModelService(apiClient, hostCache, Substitute.For<IAvatarProvider>());
|
||||
var loginCache = new TestLoginCache();
|
||||
var usage = Substitute.For<IUsageTracker>();
|
||||
var host = new RepositoryHost(apiClient, modelService, loginCache, Substitute.For<ITwoFactorChallengeHandler>(), usage);
|
||||
|
||||
var result = await host.LogIn("Cthulu", "aPassword");
|
||||
|
||||
Assert.Equal(AuthenticationResult.Success, result);
|
||||
// Only username and password were saved, never an authorization token:
|
||||
var loginInfo = await loginCache.GetLoginAsync(enterpriseHostAddress);
|
||||
Assert.Equal("Cthulu", loginInfo.UserName);
|
||||
Assert.Equal("aPassword", loginInfo.Password);
|
||||
Assert.True(host.IsLoggedIn);
|
||||
}
|
||||
|
||||
// Since we're now catching a 401 to detect a bad authoriation token, we need a test to make sure
|
||||
// real 2FA failures (like putting in a bad code) still make it through (they're also a 401, but
|
||||
// shouldn't be caught):
|
||||
[Fact]
|
||||
public async Task DoesNotFallBackToOldScopesWhenGitHubAndTwoFactorAuthFailsAndErasesLogin()
|
||||
public async Task LogsTheUserInSuccessfullyAndCachesRelevantInfo()
|
||||
{
|
||||
var apiClient = Substitute.For<IApiClient>();
|
||||
apiClient.HostAddress.Returns(HostAddress.GitHubDotComHostAddress);
|
||||
bool received1 = false, received2 = false;
|
||||
apiClient.GetOrCreateApplicationAuthenticationCode(Args.TwoFactorChallengCallback, null, false, true)
|
||||
.Returns(_ =>
|
||||
{
|
||||
received1 = true;
|
||||
return Observable.Throw<ApplicationAuthorization>(new TwoFactorChallengeFailedException());
|
||||
});
|
||||
|
||||
apiClient.GetOrCreateApplicationAuthenticationCode(Args.TwoFactorChallengCallback,
|
||||
Args.String,
|
||||
true,
|
||||
Args.Boolean)
|
||||
.Returns(_ =>
|
||||
{
|
||||
received2 = true;
|
||||
return Observable.Throw<ApplicationAuthorization>(new TwoFactorChallengeFailedException());
|
||||
});
|
||||
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("jiminy")));
|
||||
var hostCache = new InMemoryBlobCache();
|
||||
var modelService = new ModelService(apiClient, hostCache, Substitute.For<IAvatarProvider>());
|
||||
var loginManager = Substitute.For<ILoginManager>();
|
||||
loginManager.LoginFromCache(HostAddress.GitHubDotComHostAddress, Arg.Any<IGitHubClient>()).Returns(CreateUserAndScopes("baymax").User);
|
||||
var loginCache = new TestLoginCache();
|
||||
var usage = Substitute.For<IUsageTracker>();
|
||||
var host = new RepositoryHost(apiClient, modelService, loginCache, Substitute.For<ITwoFactorChallengeHandler>(), usage);
|
||||
var host = new RepositoryHost(apiClient, modelService, loginManager, loginCache, usage);
|
||||
|
||||
await host.LogIn("aUsername", "aPassowrd");
|
||||
var result = await host.LogInFromCache();
|
||||
|
||||
Assert.True(received1);
|
||||
Assert.False(received2);
|
||||
Assert.False(host.IsLoggedIn);
|
||||
var loginInfo = await loginCache.GetLoginAsync(HostAddress.GitHubDotComHostAddress);
|
||||
Assert.Equal("", loginInfo.UserName);
|
||||
Assert.Equal("", loginInfo.Password);
|
||||
Assert.Equal(AuthenticationResult.Success, result);
|
||||
var user = await hostCache.GetObject<AccountCacheItem>("user");
|
||||
Assert.NotNull(user);
|
||||
Assert.Equal("baymax", user.Login);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetriesUsingOldScopeWhenAuthenticationFailsAndIsEnterprise()
|
||||
{
|
||||
var enterpriseHostAddress = HostAddress.Create("https://enterprise.example.com/");
|
||||
var apiClient = Substitute.For<IApiClient>();
|
||||
apiClient.HostAddress.Returns(enterpriseHostAddress);
|
||||
apiClient.GetOrCreateApplicationAuthenticationCode(Args.TwoFactorChallengCallback, null, false, true)
|
||||
.Returns(Observable.Throw<ApplicationAuthorization>(new ApiException("Bad scopes", (HttpStatusCode)422)));
|
||||
apiClient.GetOrCreateApplicationAuthenticationCode(Args.TwoFactorChallengCallback, null, true, false)
|
||||
.Returns(Observable.Return(new ApplicationAuthorization("T0k3n")));
|
||||
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("jiminy")));
|
||||
var hostCache = new InMemoryBlobCache();
|
||||
var modelService = new ModelService(apiClient, hostCache, Substitute.For<IAvatarProvider>());
|
||||
var loginCache = new TestLoginCache();
|
||||
var usage = Substitute.For<IUsageTracker>();
|
||||
var host = new RepositoryHost(apiClient, modelService, loginCache, Substitute.For<ITwoFactorChallengeHandler>(), usage);
|
||||
|
||||
await host.LogIn("jiminy", "aPassowrd");
|
||||
|
||||
Assert.True(host.IsLoggedIn);
|
||||
var loginInfo = await loginCache.GetLoginAsync(enterpriseHostAddress);
|
||||
Assert.Equal("jiminy", loginInfo.UserName);
|
||||
Assert.Equal("T0k3n", loginInfo.Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SupportsGistIsTrueWhenGistScopeIsPresent()
|
||||
public async Task IncrementsLoginCount()
|
||||
{
|
||||
var apiClient = Substitute.For<IApiClient>();
|
||||
apiClient.HostAddress.Returns(HostAddress.GitHubDotComHostAddress);
|
||||
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("baymax", new[] { "gist" })));
|
||||
var hostCache = new InMemoryBlobCache();
|
||||
var modelService = new ModelService(apiClient, hostCache, Substitute.For<IAvatarProvider>());
|
||||
var modelService = Substitute.For<IModelService>();
|
||||
var loginManager = Substitute.For<ILoginManager>();
|
||||
loginManager.LoginFromCache(HostAddress.GitHubDotComHostAddress, Arg.Any<IGitHubClient>()).Returns(CreateUserAndScopes("baymax").User);
|
||||
var loginCache = new TestLoginCache();
|
||||
var usage = Substitute.For<IUsageTracker>();
|
||||
var host = new RepositoryHost(apiClient, modelService, loginCache, Substitute.For<ITwoFactorChallengeHandler>(), usage);
|
||||
var host = new RepositoryHost(apiClient, modelService, loginManager, loginCache, usage);
|
||||
|
||||
var result = await host.LogIn("baymax", "aPassword");
|
||||
var result = await host.LogInFromCache();
|
||||
|
||||
Assert.Equal(AuthenticationResult.Success, result);
|
||||
Assert.True(host.SupportsGist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SupportsGistIsFalseWhenGistScopeIsNotPresent()
|
||||
{
|
||||
var apiClient = Substitute.For<IApiClient>();
|
||||
apiClient.HostAddress.Returns(HostAddress.GitHubDotComHostAddress);
|
||||
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("baymax", new[] { "foo" })));
|
||||
var hostCache = new InMemoryBlobCache();
|
||||
var modelService = new ModelService(apiClient, hostCache, Substitute.For<IAvatarProvider>());
|
||||
var loginCache = new TestLoginCache();
|
||||
var usage = Substitute.For<IUsageTracker>();
|
||||
var host = new RepositoryHost(apiClient, modelService, loginCache, Substitute.For<ITwoFactorChallengeHandler>(), usage);
|
||||
|
||||
var result = await host.LogIn("baymax", "aPassword");
|
||||
|
||||
Assert.Equal(AuthenticationResult.Success, result);
|
||||
Assert.False(host.SupportsGist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SupportsGistIsTrueWhenScopesAreNull()
|
||||
{
|
||||
// TODO: Check assumptions here. From my conversation with @shana it seems that the first login
|
||||
// will be done with basic auth and from then on a token will be used. So if it's the first login,
|
||||
// it's from this version and so gists will be supported. However I've been unable to repro this
|
||||
// behavior.
|
||||
var apiClient = Substitute.For<IApiClient>();
|
||||
apiClient.HostAddress.Returns(HostAddress.GitHubDotComHostAddress);
|
||||
apiClient.GetUser().Returns(Observable.Return(CreateUserAndScopes("baymax")));
|
||||
var hostCache = new InMemoryBlobCache();
|
||||
var modelService = new ModelService(apiClient, hostCache, Substitute.For<IAvatarProvider>());
|
||||
var loginCache = new TestLoginCache();
|
||||
var usage = Substitute.For<IUsageTracker>();
|
||||
var host = new RepositoryHost(apiClient, modelService, loginCache, Substitute.For<ITwoFactorChallengeHandler>(), usage);
|
||||
|
||||
var result = await host.LogIn("baymax", "aPassword");
|
||||
|
||||
Assert.Equal(AuthenticationResult.Success, result);
|
||||
Assert.True(host.SupportsGist);
|
||||
await usage.Received().IncrementLoginCount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,19 +26,11 @@ public class LoginToGitHubViewModelTests
|
|||
.Returns(_ => Observable.Throw<AuthenticationResult>(new ForbiddenException(response)));
|
||||
var browser = Substitute.For<IVisualStudioBrowser>();
|
||||
var loginViewModel = new LoginToGitHubViewModel(repositoryHosts, browser);
|
||||
var message = "";
|
||||
|
||||
using (UserError.RegisterHandler<UserError>(x =>
|
||||
{
|
||||
message = x.ErrorMessage;
|
||||
return Observable.Return(RecoveryOptionResult.RetryOperation);
|
||||
}))
|
||||
{
|
||||
loginViewModel.Login.Execute(null);
|
||||
}
|
||||
loginViewModel.Login.Execute(null);
|
||||
|
||||
Assert.Equal("Make sure to use your password and not a Personal Access token to sign in.",
|
||||
message);
|
||||
loginViewModel.Error.ErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -88,8 +88,8 @@ public class RepositoryPublishViewModelTests
|
|||
public class TheConnectionsProperty : TestBaseClass
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(GitHubUrls.GitHub, "https://ghe.io" )]
|
||||
[InlineData("https://ghe.io", null)]
|
||||
[InlineData(GitHubUrls.GitHub, "https://github.enterprise" )]
|
||||
[InlineData("https://github.enterprise", null)]
|
||||
[InlineData(GitHubUrls.GitHub, null)]
|
||||
public void ConnectionsMatchHosts(string arg1, string arg2)
|
||||
{
|
||||
|
@ -123,8 +123,8 @@ public class RepositoryPublishViewModelTests
|
|||
public class TheSelectedConnectionProperty : TestBaseClass
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(GitHubUrls.GitHub, "https://ghe.io")]
|
||||
[InlineData("https://ghe.io", GitHubUrls.GitHub)]
|
||||
[InlineData(GitHubUrls.GitHub, "https://github.enterprise")]
|
||||
[InlineData("https://github.enterprise", GitHubUrls.GitHub)]
|
||||
public void DefaultsToGitHub(string arg1, string arg2)
|
||||
{
|
||||
var args = Helpers.GetArgs(arg1, arg2);
|
||||
|
@ -160,7 +160,7 @@ public class RepositoryPublishViewModelTests
|
|||
var hsts = new List<IRepositoryHost>();
|
||||
var conns = new List<IConnection>();
|
||||
Helpers.SetupConnections(hosts, cm, adds, conns, hsts, GitHubUrls.GitHub);
|
||||
Helpers.SetupConnections(hosts, cm, adds, conns, hsts, "https://ghe.io");
|
||||
Helpers.SetupConnections(hosts, cm, adds, conns, hsts, "https://github.enterprise");
|
||||
|
||||
var gitHubAccounts = new List<IAccount> { Substitute.For<IAccount>(), Substitute.For<IAccount>() };
|
||||
var enterpriseAccounts = new List<IAccount> { Substitute.For<IAccount>() };
|
||||
|
@ -342,7 +342,7 @@ public class RepositoryPublishViewModelTests
|
|||
[Fact]
|
||||
public async Task ResetsWhenSwitchingHosts()
|
||||
{
|
||||
var args = Helpers.GetArgs(GitHubUrls.GitHub, "https://ghe.io");
|
||||
var args = Helpers.GetArgs(GitHubUrls.GitHub, "https://github.enterprise");
|
||||
|
||||
var cm = Substitutes.ConnectionManager;
|
||||
var hosts = Substitute.For<IRepositoryHosts>();
|
||||
|
|
|
@ -0,0 +1,151 @@
|
|||
using System;
|
||||
using System.Reactive.Linq;
|
||||
using System.Reactive.Threading.Tasks;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Authentication;
|
||||
using GitHub.Services;
|
||||
using GitHub.ViewModels;
|
||||
using NSubstitute;
|
||||
using Octokit;
|
||||
using ReactiveUI;
|
||||
using Xunit;
|
||||
|
||||
namespace UnitTests.GitHub.App.ViewModels
|
||||
{
|
||||
public class TwoFactorDialogViewModelTests
|
||||
{
|
||||
public class TheShowMethod
|
||||
{
|
||||
[Fact]
|
||||
public void ClearsIsBusy()
|
||||
{
|
||||
var target = CreateTarget();
|
||||
var exception = new TwoFactorChallengeFailedException();
|
||||
|
||||
target.OkCommand.ExecuteAsync();
|
||||
target.Show(new TwoFactorRequiredUserError(exception));
|
||||
|
||||
Assert.False(target.IsBusy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidAuthenticationCodeIsSetWhenRetryFailed()
|
||||
{
|
||||
var target = CreateTarget();
|
||||
var exception = new TwoFactorChallengeFailedException();
|
||||
|
||||
target.Show(new TwoFactorRequiredUserError(exception));
|
||||
|
||||
Assert.True(target.InvalidAuthenticationCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OkCommandCompletesAndReturnsNullWithNoAuthorizationCode()
|
||||
{
|
||||
var target = CreateTarget();
|
||||
var exception = new TwoFactorChallengeFailedException();
|
||||
var userError = new TwoFactorRequiredUserError(exception);
|
||||
var task = target.Show(userError).ToTask();
|
||||
|
||||
target.OkCommand.Execute(null);
|
||||
var result = await task;
|
||||
|
||||
// This isn't correct but it doesn't matter as the dialog will be closed.
|
||||
Assert.True(target.IsBusy);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OkCommandCompletesAndReturnsAuthorizationCode()
|
||||
{
|
||||
var target = CreateTarget();
|
||||
var exception = new TwoFactorChallengeFailedException();
|
||||
var userError = new TwoFactorRequiredUserError(exception);
|
||||
var task = target.Show(userError).ToTask();
|
||||
|
||||
target.AuthenticationCode = "123456";
|
||||
target.OkCommand.Execute(null);
|
||||
|
||||
var result = await task;
|
||||
Assert.True(target.IsBusy);
|
||||
Assert.Equal("123456", result.AuthenticationCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResendCodeCommandCompletesAndReturnsRequestResendCode()
|
||||
{
|
||||
var target = CreateTarget();
|
||||
var exception = new TwoFactorChallengeFailedException();
|
||||
var userError = new TwoFactorRequiredUserError(exception);
|
||||
var task = target.Show(userError).ToTask();
|
||||
|
||||
target.AuthenticationCode = "123456";
|
||||
target.ResendCodeCommand.Execute(null);
|
||||
var result = await task;
|
||||
|
||||
Assert.False(target.IsBusy);
|
||||
Assert.Equal(TwoFactorChallengeResult.RequestResendCode, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShowErrorMessageIsClearedWhenAuthenticationCodeSent()
|
||||
{
|
||||
var target = CreateTarget();
|
||||
var exception = new TwoFactorChallengeFailedException();
|
||||
var userError = new TwoFactorRequiredUserError(exception);
|
||||
var task = target.Show(userError).ToTask();
|
||||
|
||||
Assert.True(target.ShowErrorMessage);
|
||||
target.ResendCodeCommand.Execute(null);
|
||||
|
||||
var result = await task;
|
||||
Assert.False(target.ShowErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public class TheCancelCommand
|
||||
{
|
||||
[Fact]
|
||||
public async Task CancelCommandCompletesAndReturnsNull()
|
||||
{
|
||||
var target = CreateTarget();
|
||||
var exception = new TwoFactorChallengeFailedException();
|
||||
var userError = new TwoFactorRequiredUserError(exception);
|
||||
var task = target.Show(userError).ToTask();
|
||||
|
||||
target.AuthenticationCode = "123456";
|
||||
target.Cancel.Execute(null);
|
||||
var result = await task;
|
||||
|
||||
Assert.False(target.IsBusy);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancel_Resets_TwoFactorType()
|
||||
{
|
||||
var target = CreateTarget();
|
||||
var exception = new TwoFactorRequiredException(TwoFactorType.Sms);
|
||||
var userError = new TwoFactorRequiredUserError(exception);
|
||||
var task = target.Show(userError).ToTask();
|
||||
|
||||
Assert.Equal(TwoFactorType.Sms, target.TwoFactorType);
|
||||
|
||||
target.Cancel.Execute(null);
|
||||
await task;
|
||||
|
||||
// TwoFactorType must be cleared here as the UIController uses it as a trigger
|
||||
// to show the 2FA dialog view.
|
||||
Assert.Equal(TwoFactorType.None, target.TwoFactorType);
|
||||
}
|
||||
}
|
||||
|
||||
static TwoFactorDialogViewModel CreateTarget()
|
||||
{
|
||||
var browser = Substitute.For<IVisualStudioBrowser>();
|
||||
var twoFactorChallengeHandler = Substitute.For<IDelegatingTwoFactorChallengeHandler>();
|
||||
return new TwoFactorDialogViewModel(browser, twoFactorChallengeHandler);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,55 +8,15 @@ using NSubstitute;
|
|||
using Rothko;
|
||||
using Xunit;
|
||||
using UnitTests;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GitHub.Factories;
|
||||
using GitHub.Api;
|
||||
|
||||
public class ConnectionManagerTests
|
||||
{
|
||||
public class TheConnectionsProperty : TestBaseClass
|
||||
{
|
||||
[Fact]
|
||||
public void IsLoadedFromCache()
|
||||
{
|
||||
const string cacheData = @"{""connections"":[{""HostUrl"":""https://github.com"", ""UserName"":""shana""},{""HostUrl"":""https://ghe.io"", ""UserName"":""haacked""}]}";
|
||||
var program = Substitute.For<IProgram>();
|
||||
program.ApplicationName.Returns("GHfVS");
|
||||
var operatingSystem = Substitute.For<IOperatingSystem>();
|
||||
operatingSystem.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData)
|
||||
.Returns(@"c:\fake");
|
||||
operatingSystem.File.Exists(@"c:\fake\GHfVS\ghfvs.connections").Returns(true);
|
||||
operatingSystem.File.ReadAllText(@"c:\fake\GHfVS\ghfvs.connections", Encoding.UTF8).Returns(cacheData);
|
||||
var manager = new ConnectionManager(program, operatingSystem, Substitutes.IVSGitServices);
|
||||
|
||||
var connections = manager.Connections;
|
||||
|
||||
Assert.Equal(2, connections.Count);
|
||||
Assert.Equal(new Uri("https://api.github.com"), connections[0].HostAddress.ApiUri);
|
||||
Assert.Equal(new Uri("https://ghe.io/api/v3/"), connections[1].HostAddress.ApiUri);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("|!This ain't no JSON what even is this?")]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("{}")]
|
||||
[InlineData(@"{""connections"":null}")]
|
||||
[InlineData(@"{""connections"":{}}")]
|
||||
public void IsEmptyWhenCacheCorrupt(string cacheJson)
|
||||
{
|
||||
var program = Substitute.For<IProgram>();
|
||||
program.ApplicationName.Returns("GHfVS");
|
||||
var operatingSystem = Substitute.For<IOperatingSystem>();
|
||||
operatingSystem.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData)
|
||||
.Returns(@"c:\fake");
|
||||
operatingSystem.File.Exists(@"c:\fake\GHfVS\ghfvs.connections").Returns(true);
|
||||
operatingSystem.File.ReadAllText(@"c:\fake\GHfVS\ghfvs.connections", Encoding.UTF8).Returns(cacheJson);
|
||||
var manager = new ConnectionManager(program, operatingSystem, Substitutes.IVSGitServices);
|
||||
|
||||
var connections = manager.Connections;
|
||||
|
||||
Assert.Equal(0, connections.Count);
|
||||
operatingSystem.File.Received().Delete(@"c:\fake\GHfVS\ghfvs.connections");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSavedToDiskWhenConnectionAdded()
|
||||
{
|
||||
|
@ -67,13 +27,16 @@ public class ConnectionManagerTests
|
|||
.Returns(@"c:\fake");
|
||||
operatingSystem.File.Exists(@"c:\fake\GHfVS\ghfvs.connections").Returns(true);
|
||||
operatingSystem.File.ReadAllText(@"c:\fake\GHfVS\ghfvs.connections", Encoding.UTF8).Returns("");
|
||||
var manager = new ConnectionManager(program, operatingSystem, Substitutes.IVSGitServices);
|
||||
var cache = Substitute.For<IConnectionCache>();
|
||||
var loginManager = Substitute.For<ILoginManager>();
|
||||
var apiClientFactory = Substitute.For<IApiClientFactory>();
|
||||
var manager = new ConnectionManager(program, Substitutes.IVSGitServices, cache, loginManager, apiClientFactory);
|
||||
|
||||
manager.Connections.Add(new Connection(manager, HostAddress.GitHubDotComHostAddress, "coolio"));
|
||||
|
||||
Assert.Equal(1, manager.Connections.Count);
|
||||
operatingSystem.File.Received().WriteAllText(@"c:\fake\GHfVS\ghfvs.connections",
|
||||
@"{""connections"":[{""HostUrl"":""https://github.com/"",""UserName"":""coolio""}]}");
|
||||
cache.Received(1).Save(Arg.Is<IEnumerable<ConnectionDetails>>(x =>
|
||||
x.SequenceEqual(new[] { new ConnectionDetails(HostAddress.GitHubDotComHostAddress, "coolio") })));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Models;
|
||||
using GitHub.Primitives;
|
||||
using GitHub.VisualStudio;
|
||||
using NSubstitute;
|
||||
using Rothko;
|
||||
using Xunit;
|
||||
|
||||
public class JsonConnectionCacheTests
|
||||
{
|
||||
public class TheLoadMethod : TestBaseClass
|
||||
{
|
||||
[Fact]
|
||||
public async Task IsLoadedFromCache()
|
||||
{
|
||||
const string cacheData = @"{""connections"":[{""HostUrl"":""https://github.com"", ""UserName"":""shana""},{""HostUrl"":""https://github.enterprise"", ""UserName"":""haacked""}]}";
|
||||
var program = Substitute.For<IProgram>();
|
||||
program.ApplicationName.Returns("GHfVS");
|
||||
var operatingSystem = Substitute.For<IOperatingSystem>();
|
||||
operatingSystem.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData)
|
||||
.Returns(@"c:\fake");
|
||||
operatingSystem.File.Exists(@"c:\fake\GHfVS\ghfvs.connections").Returns(true);
|
||||
operatingSystem.File.ReadAllText(@"c:\fake\GHfVS\ghfvs.connections", Encoding.UTF8).Returns(cacheData);
|
||||
var cache = new JsonConnectionCache(program, operatingSystem);
|
||||
|
||||
var connections = (await cache.Load()).ToList();
|
||||
Assert.Equal(2, connections.Count);
|
||||
Assert.Equal(new Uri("https://api.github.com"), connections[0].HostAddress.ApiUri);
|
||||
Assert.Equal(new Uri("https://github.enterprise/api/v3/"), connections[1].HostAddress.ApiUri);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("|!This ain't no JSON what even is this?")]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("{}")]
|
||||
[InlineData(@"{""connections"":null}")]
|
||||
[InlineData(@"{""connections"":{}}")]
|
||||
public async Task IsEmptyWhenCacheCorrupt(string cacheJson)
|
||||
{
|
||||
var program = Substitute.For<IProgram>();
|
||||
program.ApplicationName.Returns("GHfVS");
|
||||
var operatingSystem = Substitute.For<IOperatingSystem>();
|
||||
operatingSystem.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData)
|
||||
.Returns(@"c:\fake");
|
||||
operatingSystem.File.Exists(@"c:\fake\GHfVS\ghfvs.connections").Returns(true);
|
||||
operatingSystem.File.ReadAllText(@"c:\fake\GHfVS\ghfvs.connections", Encoding.UTF8).Returns(cacheJson);
|
||||
var cache = new JsonConnectionCache(program, operatingSystem);
|
||||
|
||||
var connections = (await cache.Load()).ToList();
|
||||
|
||||
Assert.Equal(0, connections.Count);
|
||||
operatingSystem.File.Received().Delete(@"c:\fake\GHfVS\ghfvs.connections");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsSavedToDisk()
|
||||
{
|
||||
var program = Substitute.For<IProgram>();
|
||||
program.ApplicationName.Returns("GHfVS");
|
||||
var operatingSystem = Substitute.For<IOperatingSystem>();
|
||||
operatingSystem.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData)
|
||||
.Returns(@"c:\fake");
|
||||
operatingSystem.File.Exists(@"c:\fake\GHfVS\ghfvs.connections").Returns(true);
|
||||
operatingSystem.File.ReadAllText(@"c:\fake\GHfVS\ghfvs.connections", Encoding.UTF8).Returns("");
|
||||
var cache = new JsonConnectionCache(program, operatingSystem);
|
||||
var connections = (await cache.Load()).ToList();
|
||||
|
||||
connections.Add(new ConnectionDetails(HostAddress.GitHubDotComHostAddress, "coolio"));
|
||||
await cache.Save(connections);
|
||||
|
||||
operatingSystem.File.Received().WriteAllText(@"c:\fake\GHfVS\ghfvs.connections",
|
||||
@"{""connections"":[{""HostUrl"":""https://github.com/"",""UserName"":""coolio""}]}");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -64,7 +64,7 @@ namespace UnitTests
|
|||
public static IRepositoryHosts RepositoryHosts { get { return Substitute.For<IRepositoryHosts>(); } }
|
||||
public static IConnection Connection { get { return Substitute.For<IConnection>(); } }
|
||||
public static IConnectionManager ConnectionManager { get { return Substitute.For<IConnectionManager>(); } }
|
||||
public static ITwoFactorChallengeHandler TwoFactorChallengeHandler { get { return Substitute.For<ITwoFactorChallengeHandler>(); } }
|
||||
public static IDelegatingTwoFactorChallengeHandler TwoFactorChallengeHandler { get { return Substitute.For<IDelegatingTwoFactorChallengeHandler>(); } }
|
||||
public static IGistPublishService GistPublishService { get { return Substitute.For<IGistPublishService>(); } }
|
||||
public static IPullRequestService PullRequestService { get { return Substitute.For<IPullRequestService>(); } }
|
||||
public static IUIProvider UIProvider { get { return Substitute.For<IUIProvider>(); } }
|
||||
|
@ -129,7 +129,7 @@ namespace UnitTests
|
|||
ret.GetService(typeof(IConnection)).Returns(Connection);
|
||||
ret.GetService(typeof(IConnectionManager)).Returns(ConnectionManager);
|
||||
ret.GetService(typeof(IAvatarProvider)).Returns(avatarProvider);
|
||||
ret.GetService(typeof(ITwoFactorChallengeHandler)).Returns(TwoFactorChallengeHandler);
|
||||
ret.GetService(typeof(IDelegatingTwoFactorChallengeHandler)).Returns(TwoFactorChallengeHandler);
|
||||
ret.GetService(typeof(IGistPublishService)).Returns(GistPublishService);
|
||||
ret.GetService(typeof(IPullRequestService)).Returns(PullRequestService);
|
||||
ret.GetService(typeof(IUIProvider)).Returns(UIProvider);
|
||||
|
@ -200,9 +200,9 @@ namespace UnitTests
|
|||
return provider.GetService(typeof(IAvatarProvider)) as IAvatarProvider;
|
||||
}
|
||||
|
||||
public static ITwoFactorChallengeHandler GetTwoFactorChallengeHandler(this IServiceProvider provider)
|
||||
public static IDelegatingTwoFactorChallengeHandler GetTwoFactorChallengeHandler(this IServiceProvider provider)
|
||||
{
|
||||
return provider.GetService(typeof(ITwoFactorChallengeHandler)) as ITwoFactorChallengeHandler;
|
||||
return provider.GetService(typeof(IDelegatingTwoFactorChallengeHandler)) as IDelegatingTwoFactorChallengeHandler;
|
||||
}
|
||||
|
||||
public static IGistPublishService GetGistPublishService(this IServiceProvider provider)
|
||||
|
|
|
@ -158,9 +158,9 @@
|
|||
<HintPath>..\..\packages\Microsoft.VisualStudio.TextManager.Interop.8.0.8.0.50727\lib\Microsoft.VisualStudio.TextManager.Interop.8.0.dll</HintPath>
|
||||
<Private>True</Private>
|
||||
</Reference>
|
||||
<Reference Include="NSubstitute, Version=1.8.1.0, Culture=neutral, PublicKeyToken=92dd2e9066daa5ca, processorArchitecture=MSIL">
|
||||
<SpecificVersion>False</SpecificVersion>
|
||||
<HintPath>..\..\packages\NSubstitute.1.8.1.0\lib\net45\NSubstitute.dll</HintPath>
|
||||
<Reference Include="NSubstitute, Version=1.10.0.0, Culture=neutral, PublicKeyToken=92dd2e9066daa5ca, processorArchitecture=MSIL">
|
||||
<HintPath>..\..\packages\NSubstitute.1.10.0.0\lib\net45\NSubstitute.dll</HintPath>
|
||||
<Private>True</Private>
|
||||
</Reference>
|
||||
<Reference Include="PresentationCore" />
|
||||
<Reference Include="PresentationFramework" />
|
||||
|
@ -214,6 +214,7 @@
|
|||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Args.cs" />
|
||||
<Compile Include="GitHub.Api\LoginManagerTests.cs" />
|
||||
<Compile Include="GitHub.Api\SimpleApiClientFactoryTests.cs" />
|
||||
<Compile Include="GitHub.Api\SimpleApiClientTests.cs" />
|
||||
<Compile Include="GitHub.App\Caches\CredentialCacheTests.cs" />
|
||||
|
@ -239,6 +240,7 @@
|
|||
<Compile Include="GitHub.App\ViewModels\RepositoryCloneViewModelTests.cs" />
|
||||
<Compile Include="GitHub.App\ViewModels\RepositoryCreationViewModelTests.cs" />
|
||||
<Compile Include="GitHub.App\ViewModels\RepositoryPublishViewModelTests.cs" />
|
||||
<Compile Include="GitHub.App\ViewModels\TwoFactorDialogViewModelTests.cs" />
|
||||
<Compile Include="GitHub.Exports.Reactive\Caches\AccountCacheItemTests.cs" />
|
||||
<Compile Include="GitHub.Exports\GitServiceTests.cs" />
|
||||
<Compile Include="GitHub.Exports\VSServicesTests.cs" />
|
||||
|
@ -250,6 +252,7 @@
|
|||
<Compile Include="GitHub.UI\Converters.cs" />
|
||||
<Compile Include="GitHub.UI\TestAutomation\ResourceValueTests.cs" />
|
||||
<Compile Include="GitHub.UI\TwoFactorInputTests.cs" />
|
||||
<Compile Include="GitHub.VisualStudio\Services\JsonConnectionCacheTests.cs" />
|
||||
<Compile Include="GitHub.VisualStudio\Services\ConnectionManagerTests.cs" />
|
||||
<Compile Include="GitHub.VisualStudio\Services\RepositoryPublishServiceTests.cs" />
|
||||
<Compile Include="GitHub.VisualStudio\TeamExplorer\Home\GraphsNavigationItemTests.cs" />
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
<package id="Microsoft.VisualStudio.Text.UI.Wpf" version="14.3.25407" targetFramework="net461" />
|
||||
<package id="Microsoft.VisualStudio.TextManager.Interop" version="7.10.6070" targetFramework="net461" />
|
||||
<package id="Microsoft.VisualStudio.TextManager.Interop.8.0" version="8.0.50727" targetFramework="net461" />
|
||||
<package id="NSubstitute" version="1.8.1.0" targetFramework="net45" />
|
||||
<package id="NSubstitute" version="1.10.0.0" targetFramework="net461" />
|
||||
<package id="Rx-Core" version="2.2.5-custom" targetFramework="net45" />
|
||||
<package id="Rx-Interfaces" version="2.2.5-custom" targetFramework="net45" />
|
||||
<package id="Rx-Linq" version="2.2.5-custom" targetFramework="net45" />
|
||||
|
|
|
@ -3,8 +3,8 @@ using System.Resources;
|
|||
using System.Runtime.InteropServices;
|
||||
|
||||
[assembly: AssemblyProduct("GitHub Extension for Visual Studio")]
|
||||
[assembly: AssemblyVersion("2.2.1.100")]
|
||||
[assembly: AssemblyFileVersion("2.2.1.100")]
|
||||
[assembly: AssemblyVersion("2.3.0.0")]
|
||||
[assembly: AssemblyFileVersion("2.3.0.0")]
|
||||
[assembly: ComVisible(false)]
|
||||
[assembly: AssemblyCompany("GitHub, Inc.")]
|
||||
[assembly: AssemblyCopyright("Copyright © GitHub, Inc. 2014-2016")]
|
||||
|
@ -16,6 +16,6 @@ using System.Runtime.InteropServices;
|
|||
namespace System
|
||||
{
|
||||
internal static class AssemblyVersionInformation {
|
||||
internal const string Version = "2.2.1.100";
|
||||
internal const string Version = "2.3.0.0";
|
||||
}
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче