using System; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using Octokit; using Tgstation.Server.Api.Models; using Tgstation.Server.Common.Tests; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.System; using Tgstation.Server.Host.Tests; namespace Tgstation.Server.Host.Utils.GitHub.Tests { [TestClass] public sealed class TestGitHubClientFactory { const string FakePrivateKey = @"-----BEGIN RSA PRIVATE KEY----- MIICWgIBAAKBgFGAf2dPjiPMmX0tD4SXtp7Mc6q4Z1uiCUuL3ms9lwAjz29d/yVv TlJIxcv8OQBamV0XTFJK+leAjBR/8ZwEumKvV/GgZzZsNny7xFgDLIugLaan2SSp uNab05d9q+btGw6f8VlSmz8EbCUm0vc803uSLW14tHTMw8g3pqsPaYWrAgMBAAEC gYAR5EaTtGg8vaaYTzMP5YpGlBV0TeUKvaiSuBLMsgmbE0FvblAbtLKZ57XcUPNm e30dbYboreMFIIgf2/J7UKJl1hNCZUeh8FU2dgArDjx7Ffl1GKyiv/j+/YBgPs0O +fZerbQSFybN/XQnb2PRt6KyXdGTxRRszY66VUxkcrYOCQJBAJm/UOqGMpZxWKVJ 8kU6+bX7nNO/uRR5KNnjW9Yu7C2jWlbw5RHSzreCwKdh/75l0fLymQ4G/j2lSLw4 v5vdtZUCQQCHtNcmaB6mpjgMd3779qKvsotUaltcqzmygS+odiIAMgeqcgASt3Fq trxIb5zO8CmfKN2D9YxNroX1aToG8P4/AkBcWLF9bEWOX15jSVsfgiDi0dKMzSeZ yHxlA07yAxURBIEKn363ietoBj05TH+UGQxV5KlR55ll5ZUemOdd83lRAkAJrSJe MbRly5pZgTfDvY1SG9gFd+P10pu1l7KPP4UjIG4dgC1zKDNlGYyJWbQDqWo+WAqb eym67EPPAObLUem/AkAbebRFp6RCTJBIFkI2GlJGabab96pEno4gdmn0fyUGhf+9 IDFvEHaDd7T08wT0pD0vTs0+UH/r/AjmXnWmrCPi -----END RSA PRIVATE KEY-----"; ILoggerFactory loggerFactory; [TestInitialize] public void Initialize() { loggerFactory = LoggerFactory.Create(builder => { builder.AddConsole(); builder.SetMinimumLevel(LogLevel.Trace); }); } [TestCleanup] public void Cleanup() { loggerFactory.Dispose(); } [TestMethod] public void TestContructionThrows() { Assert.ThrowsExactly(() => new GitHubClientFactory(null, null, null, null)); Assert.ThrowsExactly(() => new GitHubClientFactory(Mock.Of(), null, null, null)); Assert.ThrowsExactly(() => new GitHubClientFactory(Mock.Of(), Mock.Of(), null, null)); Assert.ThrowsExactly(() => new GitHubClientFactory(Mock.Of(), Mock.Of(), Mock.Of>(), null)); } [TestMethod] public async Task TestCreateBasicClient() { var mockApp = new Mock(); mockApp.SetupGet(x => x.ProductInfoHeaderValue).Returns(new ProductInfoHeaderValue("TGSTests", "1.2.3")).Verifiable(); var mockOptions = new Mock>(); var gc = new GeneralConfiguration(); Assert.IsNull(gc.GitHubAccessToken); mockOptions.SetupGet(x => x.CurrentValue).Returns(gc); var factory = new GitHubClientFactory(mockApp.Object, new BasicHttpMessageHandlerFactory(), mockOptions.Object, loggerFactory.CreateLogger()); var client = await factory.CreateClient(CancellationToken.None); Assert.IsNotNull(client); var credentials = await client.Connection.CredentialStore.GetCredentials(); Assert.AreEqual(AuthenticationType.Anonymous, credentials.AuthenticationType); gc.GitHubAccessToken = "asdfasdfasdfasdfasdfasdf"; client = await factory.CreateClient(CancellationToken.None); Assert.IsNotNull(client); credentials = await client.Connection.CredentialStore.GetCredentials(); Assert.AreEqual(AuthenticationType.Oauth, credentials.AuthenticationType); mockApp.VerifyAll(); } [TestMethod] public async Task TestCreateTokenClient() { var mockApp = new Mock(); mockApp.SetupGet(x => x.ProductInfoHeaderValue).Returns(new ProductInfoHeaderValue("TGSTests", "1.2.3")).Verifiable(); var mockOptions = new Mock>(); mockOptions.SetupGet(x => x.CurrentValue).Returns(new GeneralConfiguration()); var factory = new GitHubClientFactory(mockApp.Object, new BasicHttpMessageHandlerFactory(), mockOptions.Object, loggerFactory.CreateLogger()); await Assert.ThrowsExactlyAsync(() => factory.CreateClient(null, CancellationToken.None).AsTask()); var client = await factory.CreateClient("asdf", CancellationToken.None); Assert.IsNotNull(client); var credentials = await client.Connection.CredentialStore.GetCredentials(); Assert.AreEqual(AuthenticationType.Oauth, credentials.AuthenticationType); mockApp.VerifyAll(); } [TestMethod] public async Task TestCreateEncodedClientRealKey() { var mockApp = new Mock(); mockApp.SetupGet(x => x.ProductInfoHeaderValue).Returns(new ProductInfoHeaderValue("TGSTests", "1.2.3")).Verifiable(); var mockOptions = new Mock>(); mockOptions.SetupGet(x => x.CurrentValue).Returns(new GeneralConfiguration()); var factory = new GitHubClientFactory(mockApp.Object, new BasicHttpMessageHandlerFactory(), mockOptions.Object, loggerFactory.CreateLogger()); var appID = Environment.GetEnvironmentVariable("TGS_TEST_APP_ID"); var privateKey = Environment.GetEnvironmentVariable("TGS_TEST_APP_PRIVATE_KEY"); var repoSlug = Environment.GetEnvironmentVariable("TGS_TEST_REPO_SLUG"); if (String.IsNullOrWhiteSpace(appID)) Assert.Inconclusive("Missing App ID"); if (String.IsNullOrWhiteSpace(privateKey)) Assert.Inconclusive("Missing App Private Key"); if (String.IsNullOrWhiteSpace(repoSlug)) Assert.Inconclusive("Missing repo slug"); var fakeAccessString = $"{RepositorySettings.TgsAppPrivateKeyPrefix}{appID}:{Convert.ToBase64String(Encoding.UTF8.GetBytes(privateKey))}"; var slugSplits = repoSlug.Split('/'); Assert.AreEqual(2, slugSplits.Length); var client = await factory.CreateClientForRepository(fakeAccessString, new RepositoryIdentifier(slugSplits[0], slugSplits[1]), CancellationToken.None); Assert.IsNotNull(client); var credentials = await client.Connection.CredentialStore.GetCredentials(); Assert.AreEqual(AuthenticationType.Oauth, credentials.AuthenticationType); mockApp.VerifyAll(); } [TestMethod] public async Task TestCreateEncodedClientFakeKey() { var mockApp = new Mock(); mockApp.SetupGet(x => x.ProductInfoHeaderValue).Returns(new ProductInfoHeaderValue("TGSTests", "1.2.3")).Verifiable(); var mockOptions = new Mock>(); mockOptions.SetupGet(x => x.CurrentValue).Returns(new GeneralConfiguration()); var factory = new GitHubClientFactory(mockApp.Object, new BasicHttpMessageHandlerFactory(), mockOptions.Object, loggerFactory.CreateLogger()); await Assert.ThrowsExactlyAsync(() => factory.CreateClient(null, CancellationToken.None).AsTask()); var fakeAccessString = $"{RepositorySettings.TgsAppPrivateKeyPrefix}123:{Convert.ToBase64String(Encoding.UTF8.GetBytes(FakePrivateKey))}"; var client = await factory.CreateClientForRepository(fakeAccessString, new RepositoryIdentifier("fake_owner", "fake_name"), CancellationToken.None); Assert.IsNull(client); mockApp.VerifyAll(); } [TestMethod] public async Task TestBasicClientCaching() { var mockApp = new Mock(); mockApp.SetupGet(x => x.ProductInfoHeaderValue).Returns(new ProductInfoHeaderValue("TGSTests", "1.2.3")).Verifiable(); var mockOptions = new Mock>(); mockOptions.SetupGet(x => x.CurrentValue).Returns(new GeneralConfiguration()); var factory = new GitHubClientFactory(mockApp.Object, new BasicHttpMessageHandlerFactory(), mockOptions.Object, loggerFactory.CreateLogger()); var client1 = await factory.CreateClient(CancellationToken.None); var client2 = await factory.CreateClient("asdf", CancellationToken.None); var client3 = await factory.CreateClient(CancellationToken.None); var client4 = await factory.CreateClient("asdf", CancellationToken.None); Assert.AreSame(client1, client3); Assert.AreSame(client2, client4); } [TestMethod] public async Task TestAppClientCaching() { var mockApp = new Mock(); mockApp.SetupGet(x => x.ProductInfoHeaderValue).Returns(new ProductInfoHeaderValue("TGSTests", "1.2.3")).Verifiable(); const int MockAppId = 123; const int MockRepoId = 42; const int MockInstallationId = 542; const string MockAccessToken = "asdf_2134_im_the_installation_access_token"; DateTimeOffset? mockExpiry = null; var mockMessageHandler = new MockHttpMessageHandler( (request, cancellationToken) => { string json; string path = request.RequestUri.AbsolutePath; if (path == $"/repositories/{MockRepoId}/installation") json = @"{ ""id"": " + MockInstallationId + @", ""account"": { ""login"": ""octocat"", ""id"": 1, ""node_id"": ""MDQ6VXNlcjE="", ""avatar_url"": ""https://github.com/images/error/octocat_happy.gif"", ""gravatar_id"": """", ""url"": ""https://api.github.com/users/octocat"", ""html_url"": ""https://github.com/octocat"", ""followers_url"": ""https://api.github.com/users/octocat/followers"", ""following_url"": ""https://api.github.com/users/octocat/following{/other_user}"", ""gists_url"": ""https://api.github.com/users/octocat/gists{/gist_id}"", ""starred_url"": ""https://api.github.com/users/octocat/starred{/owner}{/repo}"", ""subscriptions_url"": ""https://api.github.com/users/octocat/subscriptions"", ""organizations_url"": ""https://api.github.com/users/octocat/orgs"", ""repos_url"": ""https://api.github.com/users/octocat/repos"", ""events_url"": ""https://api.github.com/users/octocat/events{/privacy}"", ""received_events_url"": ""https://api.github.com/users/octocat/received_events"", ""type"": ""User"", ""site_admin"": false }, ""access_tokens_url"": ""https://api.github.com/app/installations/1/access_tokens"", ""repositories_url"": ""https://api.github.com/installation/repositories"", ""html_url"": ""https://github.com/organizations/github/settings/installations/1"", ""app_id"": 1, ""target_id"": 1, ""target_type"": ""Organization"", ""permissions"": { ""checks"": ""write"", ""metadata"": ""read"", ""contents"": ""read"" }, ""events"": [ ""push"", ""pull_request"" ], ""single_file_name"": ""config.yaml"", ""has_multiple_single_files"": true, ""single_file_paths"": [ ""config.yml"", "".github/issue_TEMPLATE.md"" ], ""repository_selection"": ""selected"", ""created_at"": ""2017-07-08T16:18:44-04:00"", ""updated_at"": ""2017-07-08T16:18:44-04:00"", ""app_slug"": ""github-actions"", ""suspended_at"": null, ""suspended_by"": null }"; else if (path == $"/app/installations/{MockInstallationId}/access_tokens") json = @"{ ""token"": """ + MockAccessToken + @""", ""expires_at"": """ + (mockExpiry = DateTime.UtcNow.AddMinutes(15).AddSeconds(1)).Value.ToString("O") + @""", ""permissions"": { ""issues"": ""write"", ""contents"": ""read"" }, ""repository_selection"": ""selected"", ""repositories"": [ { ""id"": 1296269, ""node_id"": ""MDEwOlJlcG9zaXRvcnkxMjk2MjY5"", ""name"": ""Hello-World"", ""full_name"": ""octocat/Hello-World"", ""owner"": { ""login"": ""octocat"", ""id"": 1, ""node_id"": ""MDQ6VXNlcjE="", ""avatar_url"": ""https://github.com/images/error/octocat_happy.gif"", ""gravatar_id"": """", ""url"": ""https://api.github.com/users/octocat"", ""html_url"": ""https://github.com/octocat"", ""followers_url"": ""https://api.github.com/users/octocat/followers"", ""following_url"": ""https://api.github.com/users/octocat/following{/other_user}"", ""gists_url"": ""https://api.github.com/users/octocat/gists{/gist_id}"", ""starred_url"": ""https://api.github.com/users/octocat/starred{/owner}{/repo}"", ""subscriptions_url"": ""https://api.github.com/users/octocat/subscriptions"", ""organizations_url"": ""https://api.github.com/users/octocat/orgs"", ""repos_url"": ""https://api.github.com/users/octocat/repos"", ""events_url"": ""https://api.github.com/users/octocat/events{/privacy}"", ""received_events_url"": ""https://api.github.com/users/octocat/received_events"", ""type"": ""User"", ""site_admin"": false }, ""private"": false, ""html_url"": ""https://github.com/octocat/Hello-World"", ""description"": ""This your first repo!"", ""fork"": false, ""url"": ""https://api.github.com/repos/octocat/Hello-World"", ""archive_url"": ""https://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}"", ""assignees_url"": ""https://api.github.com/repos/octocat/Hello-World/assignees{/user}"", ""blobs_url"": ""https://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}"", ""branches_url"": ""https://api.github.com/repos/octocat/Hello-World/branches{/branch}"", ""collaborators_url"": ""https://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}"", ""comments_url"": ""https://api.github.com/repos/octocat/Hello-World/comments{/number}"", ""commits_url"": ""https://api.github.com/repos/octocat/Hello-World/commits{/sha}"", ""compare_url"": ""https://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}"", ""contents_url"": ""https://api.github.com/repos/octocat/Hello-World/contents/{+path}"", ""contributors_url"": ""https://api.github.com/repos/octocat/Hello-World/contributors"", ""deployments_url"": ""https://api.github.com/repos/octocat/Hello-World/deployments"", ""downloads_url"": ""https://api.github.com/repos/octocat/Hello-World/downloads"", ""events_url"": ""https://api.github.com/repos/octocat/Hello-World/events"", ""forks_url"": ""https://api.github.com/repos/octocat/Hello-World/forks"", ""git_commits_url"": ""https://api.github.com/repos/octocat/Hello-World/git/commits{/sha}"", ""git_refs_url"": ""https://api.github.com/repos/octocat/Hello-World/git/refs{/sha}"", ""git_tags_url"": ""https://api.github.com/repos/octocat/Hello-World/git/tags{/sha}"", ""git_url"": ""git:github.com/octocat/Hello-World.git"", ""issue_comment_url"": ""https://api.github.com/repos/octocat/Hello-World/issues/comments{/number}"", ""issue_events_url"": ""https://api.github.com/repos/octocat/Hello-World/issues/events{/number}"", ""issues_url"": ""https://api.github.com/repos/octocat/Hello-World/issues{/number}"", ""keys_url"": ""https://api.github.com/repos/octocat/Hello-World/keys{/key_id}"", ""labels_url"": ""https://api.github.com/repos/octocat/Hello-World/labels{/name}"", ""languages_url"": ""https://api.github.com/repos/octocat/Hello-World/languages"", ""merges_url"": ""https://api.github.com/repos/octocat/Hello-World/merges"", ""milestones_url"": ""https://api.github.com/repos/octocat/Hello-World/milestones{/number}"", ""notifications_url"": ""https://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}"", ""pulls_url"": ""https://api.github.com/repos/octocat/Hello-World/pulls{/number}"", ""releases_url"": ""https://api.github.com/repos/octocat/Hello-World/releases{/id}"", ""ssh_url"": ""git@github.com:octocat/Hello-World.git"", ""stargazers_url"": ""https://api.github.com/repos/octocat/Hello-World/stargazers"", ""statuses_url"": ""https://api.github.com/repos/octocat/Hello-World/statuses/{sha}"", ""subscribers_url"": ""https://api.github.com/repos/octocat/Hello-World/subscribers"", ""subscription_url"": ""https://api.github.com/repos/octocat/Hello-World/subscription"", ""tags_url"": ""https://api.github.com/repos/octocat/Hello-World/tags"", ""teams_url"": ""https://api.github.com/repos/octocat/Hello-World/teams"", ""trees_url"": ""https://api.github.com/repos/octocat/Hello-World/git/trees{/sha}"", ""clone_url"": ""https://github.com/octocat/Hello-World.git"", ""mirror_url"": ""git:git.example.com/octocat/Hello-World"", ""hooks_url"": ""https://api.github.com/repos/octocat/Hello-World/hooks"", ""svn_url"": ""https://svn.github.com/octocat/Hello-World"", ""homepage"": ""https://github.com"", ""language"": null, ""forks_count"": 9, ""stargazers_count"": 80, ""watchers_count"": 80, ""size"": 108, ""default_branch"": ""master"", ""open_issues_count"": 0, ""is_template"": true, ""topics"": [ ""octocat"", ""atom"", ""electron"", ""api"" ], ""has_issues"": true, ""has_projects"": true, ""has_wiki"": true, ""has_pages"": false, ""has_downloads"": true, ""archived"": false, ""disabled"": false, ""visibility"": ""public"", ""pushed_at"": ""2011-01-26T19:06:43Z"", ""created_at"": ""2011-01-26T19:01:12Z"", ""updated_at"": ""2011-01-26T19:14:43Z"", ""permissions"": { ""admin"": false, ""push"": false, ""pull"": true }, ""allow_rebase_merge"": true, ""template_repository"": null, ""temp_clone_token"": ""ABTLWHOULUVAXGTRYU7OC2876QJ2O"", ""allow_squash_merge"": true, ""allow_auto_merge"": false, ""delete_branch_on_merge"": true, ""allow_merge_commit"": true, ""subscribers_count"": 42, ""network_count"": 0, ""license"": { ""key"": ""mit"", ""name"": ""MIT License"", ""url"": ""https://api.github.com/licenses/mit"", ""spdx_id"": ""MIT"", ""node_id"": ""MDc6TGljZW5zZW1pdA=="", ""html_url"": ""https://github.com/licenses/mit"" }, ""forks"": 1, ""open_issues"": 1, ""watchers"": 1 } ] }"; else { Assert.Fail($"Unrecognized request path: {request.RequestUri}"); return Task.FromResult(new HttpResponseMessage()); } var result = new HttpResponseMessage(); result.StatusCode = HttpStatusCode.OK; result.Content = new StringContent(json); result.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); return Task.FromResult(result); }); var mockMessageHandlerFactory = new Mock(); mockMessageHandlerFactory.Setup(x => x.CreateHandler(It.IsAny())).Returns(mockMessageHandler); var mockOptions = new Mock>(); mockOptions.SetupGet(x => x.CurrentValue).Returns(new GeneralConfiguration()); var factory = new GitHubClientFactory(mockApp.Object, mockMessageHandlerFactory.Object, mockOptions.Object, loggerFactory.CreateLogger()); var fakeAccessString = $"{RepositorySettings.TgsAppPrivateKeyPrefix}{MockAppId}:{Convert.ToBase64String(Encoding.UTF8.GetBytes(FakePrivateKey))}"; var repoIdentifier = new RepositoryIdentifier(MockRepoId); var client1 = await factory.CreateClientForRepository(fakeAccessString, repoIdentifier, CancellationToken.None); Assert.IsNotNull(client1); Assert.AreEqual(MockAccessToken, client1.Connection.Credentials.GetToken()); var client2 = await factory.CreateClientForRepository(fakeAccessString, repoIdentifier, CancellationToken.None); Assert.AreSame(client1, client2); IGitHubClient client3 = null; for (var i = 0; i < 100; ++i) { await Task.Delay(TimeSpan.FromMilliseconds(50)); client3 = await factory.CreateClientForRepository(fakeAccessString, repoIdentifier, CancellationToken.None); if (client3 != client2) break; } Assert.AreNotSame(client2, client3); Assert.IsNotNull(client3); Assert.AreEqual(MockAccessToken, client3.Connection.Credentials.GetToken()); } } }