From 02457e1344bf608becf61cec2e8072cae3a83089 Mon Sep 17 00:00:00 2001 From: Matthew Edmondson Date: Mon, 21 Sep 2026 09:34:39 +0000 Subject: [PATCH 1/2] fix: enumerate repositories on the route the credential entitles [patch] GitHubProvider.GetRepositoriesAsync called GET /users/{login}/repos alone, which is public-only even with a token, while AzureDevOpsProvider's equivalent returns everything its token can see. Two implementations of one IGitHostingProvider method diverged in coverage with no signal a caller could read, and an organisation's private repositories were invisible to a credential that could plainly see them. GitHub publishes no single endpoint that both honours Owner and reveals what a credential is entitled to, so the route is now chosen from what the owner is: GET /orgs/{org}/repos for an organisation, GET /user/repos with affiliation=owner once GET /user has confirmed the configured owner is the credential's own account, and the existing public route for any other user, which is genuinely all GitHub offers there. The owner's type is read from GET /users/{login} rather than inferred from GET /orgs/{login}/repos answering 404. The inference is cheaper and wrong: a token without read:org, or one not authorised for an org enforcing SSO, is answered 404 for an organisation that exists, and that would quietly demote it back to the public-only route under a condition nothing would report. An unauthenticated provider skips both probes, because without a credential every route collapses to the same public answer. A credential with no user identity - a GitHub App installation token, whose GET /user is answered 403 - falls back to the public route rather than turning a call that previously succeeded into a thrown exception; a 401 still propagates. IGitHostingProvider.GetRepositoriesAsync now states the coverage every provider promises, and names the one case where the public-only fallback applies, instead of leaving each provider to describe itself and the caller to compare them. Fixes #100 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WTqLSCKAY2VyL5ozhCvhgA --- .../Hosting/GitHubProviderTests.cs | 173 +++++++++++++++++- GitIntegration/GitHubProvider.cs | 125 +++++++++++-- GitIntegration/Hosting/IGitHostingProvider.cs | 23 ++- 3 files changed, 297 insertions(+), 24 deletions(-) diff --git a/GitIntegration.Test/Hosting/GitHubProviderTests.cs b/GitIntegration.Test/Hosting/GitHubProviderTests.cs index e27d013..77262cc 100644 --- a/GitIntegration.Test/Hosting/GitHubProviderTests.cs +++ b/GitIntegration.Test/Hosting/GitHubProviderTests.cs @@ -85,13 +85,34 @@ private static string SingleRepositoryArray(string name) => [{"id":90000001,"name":"{{name}}","html_url":"https://github.com/example-user/example-repo","clone_url":"https://github.com/example-user/example-repo.git"}] """; + /// + /// Builds the response GET /users/{login} and GET /user return, carrying only the + /// two fields 's route selection reads. + /// + /// + /// Written inline for the reason is: the routing decision + /// reads type and login and nothing else, and a captured account payload would + /// carry thirty fields whose presence no assertion depends on, inviting a later reader to wonder + /// which of them mattered. + /// + /// The value to send as the account's login field. + /// The value to send as the account's type field — GitHub sends User or Organization. + private static string AccountPayload(string login, string type) => + $$""" + {"id":90000002,"login":"{{login}}","type":"{{type}}"} + """; + [TestMethod] public async Task AppliesATokenCredentialToTheRequestAsync() { PersonaGUID persona = CredentialCache.CreatePersonaGUID(); CredentialCache.Instance.AddOrReplace(persona, new CredentialWithToken { Token = "ghp_abc123".As() }); + // Two responses because a credential is supplied: an authenticated enumeration establishes the + // owner's type before choosing a route. The assertion is on the first request either way — the + // credential has to reach every request this provider issues, probe included. using FakeHttpMessageHandler handler = new FakeHttpMessageHandler() + .Respond(HttpStatusCode.OK, AccountPayload("contoso", "Organization"), ("Content-Type", "application/json")) .Respond(HttpStatusCode.OK, Fixture("github-repositories.json"), ("Content-Type", "application/json")); GitHubProvider provider = new() { Owner = "contoso".As(), PersonaGUID = persona, Handler = handler }; @@ -109,6 +130,7 @@ public async Task SendsBearerAuthForABearerTokenCredentialAsync() // scheme for each: "Token" for a PAT, "Bearer" for a JWT such as a GitHub App token. Proves // the BearerToken kind reaches the Octokit client as Bearer rather than collapsing to Token. using FakeHttpMessageHandler handler = new FakeHttpMessageHandler() + .Respond(HttpStatusCode.OK, AccountPayload("contoso", "Organization"), ("Content-Type", "application/json")) .Respond(HttpStatusCode.OK, Fixture("github-repositories.json"), ("Content-Type", "application/json")); GitHubProvider provider = new() { @@ -133,9 +155,9 @@ public async Task EnumeratesRepositoriesForTheOwnerAsync() await provider.GetRepositoriesAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); // The route this provider calls is a documented part of its contract, not an Octokit detail: - // GET /users/{login}/repos is exactly why GetRepositoriesAsync returns public repositories - // only, and GET /user/repos would silently return the token's own repositories instead of the - // configured owner's. Nothing else in the suite pins it. + // GET /users/{login}/repos is the owner-honouring route that needs no credential, and + // GET /user/repos would silently return the token's own repositories instead of the configured + // owner's. Nothing else in the suite pins it for the unauthenticated case. Assert.AreEqual("/users/contoso/repos", handler.Requests[0].Uri.AbsolutePath); // Asserted on the parsed fields of the first two entries, against the fixture's real @@ -151,6 +173,151 @@ public async Task EnumeratesRepositoriesForTheOwnerAsync() Assert.AreEqual("https://github.com/example-user/example-repo-2".As(), repositories[1].WebURI); } + [TestMethod] + public async Task IssuesNoOwnerTypeProbeWithoutACredentialAsync() + { + // The unauthenticated enumeration is one request, not three. Every route this provider can + // choose between collapses to the same public answer without a credential, so probing which + // one to ask for would spend two requests distinguishing identical answers. Asserted as an + // exact count rather than on the one path, because a probe that ran and was ignored would + // leave that path assertion passing. + using FakeHttpMessageHandler handler = new FakeHttpMessageHandler() + .RespondToPath("/users/contoso/repos", HttpStatusCode.OK, SingleRepositoryArray("public-repo"), ("Content-Type", "application/json")); + GitHubProvider provider = new() { Owner = "contoso".As(), Handler = handler }; + + IReadOnlyList repositories = + await provider.GetRepositoriesAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + + Assert.AreEqual(1, handler.Requests.Count); + Assert.AreEqual("public-repo".As(), repositories[0].Name); + } + + [TestMethod] + public async Task EnumeratesAnOrganisationOnTheOrgRouteWhenAuthenticatedAsync() + { + // The case this whole route selection exists for. GET /users/{login}/repos is public-only even + // with a token, so an organisation's private repositories were invisible to a credential that + // could plainly see them — the divergence from AzureDevOpsProvider, which reports everything + // its token reaches, under one interface that promises the same coverage of both. + // + // RespondToPath rather than an assertion on the recorded URI: a scripted success returned + // regardless of route would let the wrong route parse and map a body GitHub would never have + // sent it, and the test would pass on a provider that still asks the public endpoint. + using FakeHttpMessageHandler handler = new FakeHttpMessageHandler() + .RespondToPath("/users/contoso", HttpStatusCode.OK, AccountPayload("contoso", "Organization"), ("Content-Type", "application/json")) + .RespondToPath("/orgs/contoso/repos", HttpStatusCode.OK, SingleRepositoryArray("private-repo"), ("Content-Type", "application/json")); + GitHubProvider provider = new() + { + Owner = "contoso".As(), + Handler = handler, + CredentialSource = () => HostingCredential.FromToken("ghp_abc123"), + }; + + IReadOnlyList repositories = + await provider.GetRepositoriesAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + + Assert.AreEqual(2, handler.Requests.Count); + Assert.AreEqual("/users/contoso", handler.Requests[0].Uri.AbsolutePath); + Assert.AreEqual("/orgs/contoso/repos", handler.Requests[1].Uri.AbsolutePath); + + // The repository is named for the route that produced it: the public route is scripted to + // answer nothing here, so this name can only have arrived through /orgs/contoso/repos. + Assert.AreEqual("private-repo".As(), repositories[0].Name); + } + + [TestMethod] + public async Task EnumeratesTheCredentialsOwnAccountOnTheCurrentUserRouteAsync() + { + // A user owner that is the credential's own account. GET /user/repos is the only route that + // reveals that account's private repositories, and it is reachable only once GET /user has + // confirmed the configured owner IS that account — the endpoint describes the token's own + // repositories regardless of which owner was configured, so reaching it any earlier would + // stop honouring Owner. + using FakeHttpMessageHandler handler = new FakeHttpMessageHandler() + // Scripted at the owner's own casing, because that is the spelling this provider sends: + // GitHub resolves a login case-insensitively, the recorded path is compared exactly, and + // nothing here canonicalises Owner before putting it in a URI. + .RespondToPath("/users/Octocat", HttpStatusCode.OK, AccountPayload("octocat", "User"), ("Content-Type", "application/json")) + .RespondToPath("/user", HttpStatusCode.OK, AccountPayload("octocat", "User"), ("Content-Type", "application/json")) + .RespondToPath("/user/repos", HttpStatusCode.OK, SingleRepositoryArray("private-repo"), ("Content-Type", "application/json")); + GitHubProvider provider = new() + { + // Deliberately cased differently from the login GET /user reports. GitHub logins are + // unique case-insensitively, so "Octocat" and "octocat" name one account, and an ordinal + // comparison here would demote the owner's own repositories to the public-only route. + Owner = "Octocat".As(), + Handler = handler, + CredentialSource = () => HostingCredential.FromToken("ghp_abc123"), + }; + + IReadOnlyList repositories = + await provider.GetRepositoriesAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + + Assert.AreEqual(3, handler.Requests.Count); + Assert.AreEqual("/user/repos", handler.Requests[2].Uri.AbsolutePath); + + // Owner affiliation, not the unfiltered default: GET /user/repos with no affiliation also + // returns repositories the account merely collaborates on or reaches through an organisation, + // which would report another owner's work under this owner's name. + Assert.AreEqual("?affiliation=owner", handler.Requests[2].Uri.Query); + + Assert.AreEqual("private-repo".As(), repositories[0].Name); + } + + [TestMethod] + public async Task FallsBackToThePublicRouteForAUserOtherThanTheCredentialsOwnAsync() + { + // The one case where the public-only limit is real rather than a defect: GitHub publishes no + // authenticated route that honours an arbitrary user owner. Worth pinning, because the + // tempting shortcut — sending GET /user/repos anyway — would answer with the token holder's + // own repositories under someone else's name. + using FakeHttpMessageHandler handler = new FakeHttpMessageHandler() + .RespondToPath("/users/someone-else", HttpStatusCode.OK, AccountPayload("someone-else", "User"), ("Content-Type", "application/json")) + .RespondToPath("/user", HttpStatusCode.OK, AccountPayload("octocat", "User"), ("Content-Type", "application/json")) + .RespondToPath("/users/someone-else/repos", HttpStatusCode.OK, SingleRepositoryArray("public-repo"), ("Content-Type", "application/json")); + GitHubProvider provider = new() + { + Owner = "someone-else".As(), + Handler = handler, + CredentialSource = () => HostingCredential.FromToken("ghp_abc123"), + }; + + IReadOnlyList repositories = + await provider.GetRepositoriesAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + + Assert.AreEqual(3, handler.Requests.Count); + Assert.AreEqual("/users/someone-else/repos", handler.Requests[2].Uri.AbsolutePath); + Assert.AreEqual("public-repo".As(), repositories[0].Name); + } + + [TestMethod] + public async Task FallsBackToThePublicRouteWhenTheCredentialHasNoUserIdentityAsync() + { + // A GitHub App installation token authenticates but belongs to no user, and GitHub answers its + // GET /user with 403. That must not become a thrown GitHostingAuthenticationException: such a + // credential enumerated a user owner's public repositories perfectly well before any of this + // routing existed, and turning a working call into a failure is a worse regression than the + // under-reporting the routing was added to fix. A 401 is deliberately not covered here — + // a credential GitHub rejects outright still propagates, and + // TranslatesAnUnauthorizedResponseToGitHostingAuthenticationExceptionAsync pins that. + using FakeHttpMessageHandler handler = new FakeHttpMessageHandler() + .RespondToPath("/users/octocat", HttpStatusCode.OK, AccountPayload("octocat", "User"), ("Content-Type", "application/json")) + .RespondToPath("/user", HttpStatusCode.Forbidden, "{\"message\":\"Resource not accessible by integration\"}", ("Content-Type", "application/json")) + .RespondToPath("/users/octocat/repos", HttpStatusCode.OK, SingleRepositoryArray("public-repo"), ("Content-Type", "application/json")); + GitHubProvider provider = new() + { + Owner = "octocat".As(), + Handler = handler, + CredentialSource = () => HostingCredential.FromBearerToken("eyJ0eXAiOiJKV1Qi"), + }; + + IReadOnlyList repositories = + await provider.GetRepositoriesAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + + Assert.AreEqual("/users/octocat/repos", handler.Requests[2].Uri.AbsolutePath); + Assert.AreEqual("public-repo".As(), repositories[0].Name); + } + [TestMethod] public async Task ReportsNoLocalPathForAnEnumeratedRepositoryAsync() { diff --git a/GitIntegration/GitHubProvider.cs b/GitIntegration/GitHubProvider.cs index ade82c1..0f03d18 100644 --- a/GitIntegration/GitHubProvider.cs +++ b/GitIntegration/GitHubProvider.cs @@ -66,21 +66,24 @@ public sealed class GitHubProvider : GitProvider /// /// /// Adds to the interface's remarks rather than restating them. - /// says only that implementations differ - /// in coverage and points here for this host's specifics, so the two texts have one job each and - /// neither is a copy of the other. An edit that moves this explanation must leave that pointer - /// aimed somewhere real. + /// states the coverage every provider + /// promises and points here for the routes this host reaches it by, so the two texts have one + /// job each and neither is a copy of the other. An edit that moves this explanation must leave + /// that pointer aimed somewhere real. /// - /// Calls GitHub's GET /users/{login}/repos, which returns only 's - /// public repositories. Supplying a token does not widen this: that endpoint does not - /// honour authentication to reveal private repositories the way GET /user/repos would for - /// the token's own account, and switching to that endpoint would silently stop honouring - /// — it always describes the token's own repositories, regardless - /// of which owner was configured, which would break callers who name someone else's owner on - /// purpose. A caller that needs private repositories for a specific owner has no equivalent - /// through this provider today; do not assume this method's coverage matches an - /// AzureDevOpsProvider equivalent, whose token can see everything it has access to under - /// the same interface. + /// GitHub has no single endpoint that both honours and reveals + /// the private repositories a credential can see, so the route is chosen from what the owner is. + /// GET /orgs/{org}/repos does both for an organisation. GET /user/repos does both + /// for the credential's own account, but only for that account — it always describes the token's + /// own repositories regardless of which owner was configured, so it is reached only once the + /// configured owner has been confirmed to be that account. GET /users/{login}/repos, + /// which this method used to be alone in calling, honours any owner but is public-only, and + /// remains the answer for a user who is not the credential's own: that is genuinely all GitHub + /// offers there. + /// + /// + /// Which one applies is established by , whose remarks + /// carry the per-branch reasoning and the cost each branch pays in requests. /// /// public override async Task> GetRepositoriesAsync(CancellationToken cancellationToken = default) @@ -92,7 +95,7 @@ public override async Task> GetRepositoriesAsync(Ca try { - IReadOnlyList repositories = await client.Repository.GetAllForUser(Owner.WeakString).ConfigureAwait(false); + IReadOnlyList repositories = await GetOwnerRepositoriesAsync(client).ConfigureAwait(false); return [.. repositories.Select(ToGitRepository)]; } catch (ApiException exception) @@ -101,6 +104,98 @@ public override async Task> GetRepositoriesAsync(Ca } } + /// + /// Lists 's repositories on whichever route reaches the widest + /// set this provider's credential is entitled to. + /// + /// + /// + /// An unauthenticated provider skips both probes below and calls the public route directly. This + /// is not a shortcut taken for speed: without a credential every route collapses to the same + /// public answer — GET /orgs/{org}/repos and GET /user/repos reveal a private + /// repository only to a credential entitled to it — so the probes would spend requests + /// establishing which of three identical answers to ask for. + /// + /// + /// Authenticated, the owner's type decides the route, and it is read from + /// GET /users/{login} rather than inferred from GET /orgs/{login}/repos answering + /// 404. The inference is the cheaper probe and the wrong one: a token without + /// read:org, or one not authorised for an organisation that enforces SSO, is answered + /// 404 by that route for an organisation that plainly exists, and the inference would + /// quietly demote it to the public-only user route — reinstating the exact under-reporting this + /// method exists to remove, under a condition nothing would report. GET /users/{login} is + /// a public endpoint whose type no credential's scope can change, and its 404 says + /// the owner does not exist, which surfaces as instead + /// of an empty list. + /// + /// + /// A user owner then costs a second probe, GET /user, because GET /user/repos is + /// correct only when the configured owner is the credential's own account, and nothing + /// short of asking establishes that. A credential that has no user to report — a GitHub App + /// installation token, whose GET /user is answered 403 — is not a failure to + /// surface: it means only that this branch cannot apply, and the public route is what such a + /// credential could reach anyway, so the enumeration continues there rather than throwing where + /// it previously succeeded. A 401 is left to propagate, because a credential GitHub + /// rejects outright is a failure the caller has to see. + /// + /// + /// The client this call's transport is wired to. + /// The repositories GitHub reported on the chosen route. + private async Task> GetOwnerRepositoriesAsync(GitHubClient client) + { + string owner = Owner.WeakString; + + if (!IsAuthenticated) + { + return await client.Repository.GetAllForUser(owner).ConfigureAwait(false); + } + + User account = await client.User.Get(owner).ConfigureAwait(false); + + if (IsOrganisation(account)) + { + return await client.Repository.GetAllForOrg(owner).ConfigureAwait(false); + } + + string? authenticatedLogin; + + try + { + authenticatedLogin = (await client.User.Current().ConfigureAwait(false)).Login; + } + catch (ForbiddenException) + { + authenticatedLogin = null; + } + + // Ordinal-ignore-case, because GitHub logins are unique case-insensitively and a caller who + // configured "Octocat" for the account GitHub reports as "octocat" named the same account. + return string.Equals(authenticatedLogin, owner, StringComparison.OrdinalIgnoreCase) + // Affiliation rather than the default: GetAllForCurrent() unfiltered also returns + // repositories the account merely collaborates on or reaches through an organisation, + // which are not Owner's repositories and would report a different owner's work under + // this owner's name. Owner is the affiliation that makes this route mean what + // GET /users/{login}/repos means, minus the public-only limit. + ? await client.Repository.GetAllForCurrent(new RepositoryRequest { Affiliation = RepositoryAffiliation.Owner }).ConfigureAwait(false) + : await client.Repository.GetAllForUser(owner).ConfigureAwait(false); + } + + /// + /// Reports whether an account GitHub described is an organisation. + /// + /// + /// Asks whether GitHub said "organisation" rather than whether it said "user", so every other + /// answer routes to the user branches: is nullable and + /// already carries and + /// alongside the two this decision is really about. An + /// omitted type, and an account kind GitHub adds later, both land on the route this + /// method's caller took before any of these branches existed, which is the answer that cannot be + /// wrong about coverage — only conservative about it. + /// + /// The account GET /users/{login} reported. + /// when GitHub called the account an organisation; otherwise, . + private static bool IsOrganisation(User account) => account.Type == AccountType.Organization; + /// internal override async Task> GetPullRequestsCoreAsync(GitRepositoryAddress repositoryAddress, CancellationToken cancellationToken) { diff --git a/GitIntegration/Hosting/IGitHostingProvider.cs b/GitIntegration/Hosting/IGitHostingProvider.cs index efc3242..077ac29 100644 --- a/GitIntegration/Hosting/IGitHostingProvider.cs +++ b/GitIntegration/Hosting/IGitHostingProvider.cs @@ -55,12 +55,23 @@ public interface IGitHostingProvider /// Retrieves the repositories this provider's owner has, from the remote service. /// /// - /// Implementations are not guaranteed to agree on exactly which of the owner's repositories this - /// returns — each host's own API shapes that. GitHub's implementation, in particular, returns - /// only the owner's public repositories, even when an authenticated credential is - /// supplied; see for why. A caller that needs a - /// specific host's exact coverage should consult that provider's own remarks rather than assume - /// parity across hosts. + /// + /// Every implementation returns the owner's repositories this provider's credential is entitled + /// to see, private ones included, and falls back to the owner's public repositories only where + /// the host publishes no owner-scoped route that a credential widens. That is the contract + /// callers may rely on, and it is stated here rather than left to each provider to describe its + /// own behaviour: a caller who has to read two providers' remarks and work out what they have in + /// common is a caller who will get it wrong. + /// + /// + /// The fallback is not hypothetical, and where it applies is worth naming. + /// never takes it — its enumeration is entitlement-scoped + /// throughout. takes it in exactly one case: an owner that is a + /// GitHub user other than the credential's own account, for which GitHub publishes no + /// authenticated owner-scoped route at all. An organisation owner, and the credential's own user + /// account, both reach the full entitled set. See + /// for the routes that produces. + /// /// /// /// A token to cancel the request. Every provider checks it before issuing a request. Whether it From 43865081c467ef04ac8491bb63db8f750271e149 Mon Sep 17 00:00:00 2001 From: Matthew Edmondson Date: Mon, 21 Sep 2026 09:45:56 +0000 Subject: [PATCH 2/2] test: use the MSTest count assertion the analyzer points at [patch] SonarCloud's analysis of the new tests raised MSTEST0037 on the four handler.Requests.Count checks, suggesting Assert.HasCount. Adopted, which also drops the .Count dereference from each assertion. MSTEST0054 (TestContext.CancellationToken over TestContext.CancellationTokenSource.Token) is left alone on the same grounds dccc7a8 recorded: the older form is used at 37 call sites in this file alone and hundreds across the suite, so converting the five new lines would make them the only outliers. The pre-existing Assert.AreEqual(2, handler.Requests.Count) in LeavesAnInjectedHandlerUndisposedAndUsableForASecondCallAsync is untouched, since it is not this PR's line to change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WTqLSCKAY2VyL5ozhCvhgA --- GitIntegration.Test/Hosting/GitHubProviderTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/GitIntegration.Test/Hosting/GitHubProviderTests.cs b/GitIntegration.Test/Hosting/GitHubProviderTests.cs index 77262cc..4f2d311 100644 --- a/GitIntegration.Test/Hosting/GitHubProviderTests.cs +++ b/GitIntegration.Test/Hosting/GitHubProviderTests.cs @@ -188,7 +188,7 @@ public async Task IssuesNoOwnerTypeProbeWithoutACredentialAsync() IReadOnlyList repositories = await provider.GetRepositoriesAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); - Assert.AreEqual(1, handler.Requests.Count); + Assert.HasCount(1, handler.Requests); Assert.AreEqual("public-repo".As(), repositories[0].Name); } @@ -216,7 +216,7 @@ public async Task EnumeratesAnOrganisationOnTheOrgRouteWhenAuthenticatedAsync() IReadOnlyList repositories = await provider.GetRepositoriesAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); - Assert.AreEqual(2, handler.Requests.Count); + Assert.HasCount(2, handler.Requests); Assert.AreEqual("/users/contoso", handler.Requests[0].Uri.AbsolutePath); Assert.AreEqual("/orgs/contoso/repos", handler.Requests[1].Uri.AbsolutePath); @@ -253,7 +253,7 @@ public async Task EnumeratesTheCredentialsOwnAccountOnTheCurrentUserRouteAsync() IReadOnlyList repositories = await provider.GetRepositoriesAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); - Assert.AreEqual(3, handler.Requests.Count); + Assert.HasCount(3, handler.Requests); Assert.AreEqual("/user/repos", handler.Requests[2].Uri.AbsolutePath); // Owner affiliation, not the unfiltered default: GET /user/repos with no affiliation also @@ -285,7 +285,7 @@ public async Task FallsBackToThePublicRouteForAUserOtherThanTheCredentialsOwnAsy IReadOnlyList repositories = await provider.GetRepositoriesAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); - Assert.AreEqual(3, handler.Requests.Count); + Assert.HasCount(3, handler.Requests); Assert.AreEqual("/users/someone-else/repos", handler.Requests[2].Uri.AbsolutePath); Assert.AreEqual("public-repo".As(), repositories[0].Name); }