File: GitHubApiClientTests.cs
Web Access
Project: src\tests\AnalyzeCiFailure.Tests\AnalyzeCiFailure.Tests.csproj (AnalyzeCiFailure.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Net;
using System.Text.Json.Nodes;
using Aspire.TestTools;
using Infrastructure.Tests.TestServices;
using Xunit;
 
namespace Infrastructure.Tests;
 
/// <summary>Exercises authentication, pagination, redirects, bounded streaming and non-replayed mutations.</summary>
public sealed class GitHubApiClientTests
{
    private static readonly Uri s_api = new("https://api.github.com/");
 
    [Fact]
    public async Task TokenIsLazyAndResolvedOnceAcrossConcurrentRequests()
    {
        var tokenCalls = 0;
        using var handler = new GitHubHttpMessageHandler((request, _) =>
        {
            if (request.RequestUri!.AbsolutePath != "/")
            {
                Assert.Equal("Bearer example-token", request.Headers.Authorization?.ToString());
                Assert.Equal("application/vnd.github+json", Assert.Single(request.Headers.Accept).MediaType);
            }
            return Task.FromResult(GitHubHttpMessageHandler.Json("{}"));
        });
        using var http = new HttpClient(handler);
        var client = new GitHubApiClient(
            http,
            _ =>
        {
            Interlocked.Increment(ref tokenCalls);
            return Task.FromResult("example-token");
        },
            s_api);
        Assert.Equal(0, tokenCalls);
        await Task.WhenAll(Enumerable.Range(start: 0, count: 5).Select(_ => client.GetJsonAsync("repos/a/b", default)));
        Assert.Equal(1, tokenCalls);
        client.Dispose();
        using var response = await http.GetAsync(s_api);
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }
 
    [Fact]
    public async Task PaginationPreservesRawShortPagesAndComparisonMetadata()
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            return Task.FromResult(calls == 1
                ? GitHubHttpMessageHandler.Json(
                    """{"status":"ahead","commits":[{"sha":"one"}]}""",
                    """<https://api.github.com/compare?page=2>; rel="next", <https://api.github.com/compare?page=2>; rel="last" """)
                : GitHubHttpMessageHandler.Json("""{"status":"ahead","commits":[]}"""));
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var pages = await client.GetPagesAsync("compare?per_page=100", default);
        Assert.Equal(2, pages.Count);
        Assert.Equal("ahead", pages[0]!["status"]!.GetValue<string>());
        Assert.Equal("one", pages[0]!["commits"]![0]!["sha"]!.GetValue<string>());
        Assert.Empty(pages[1]!["commits"]!.AsArray());
    }
 
    [Fact]
    public async Task RelativePaginationLinksResolveAgainstCurrentPage()
    {
        var paths = new List<string>();
        using var handler = new GitHubHttpMessageHandler((request, _) =>
        {
            paths.Add(request.RequestUri!.AbsolutePath);
            return Task.FromResult(paths.Count switch
            {
                1 => GitHubHttpMessageHandler.Json("[]", """<nested/page>; rel="next" """),
                2 => GitHubHttpMessageHandler.Json("[]", """<final>; rel="next" """),
                _ => GitHubHttpMessageHandler.Json("[]")
            });
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        Assert.Equal(3, (await client.GetPagesAsync("start", default)).Count);
        Assert.Equal(["/start", "/nested/page", "/nested/final"], paths);
    }
 
    [Fact]
    public async Task TokenProviderFailuresHaveSanitizedRequestContext()
    {
        using var handler = new GitHubHttpMessageHandler((_, _) => throw new InvalidOperationException("Must not send."));
        using var http = new HttpClient(handler);
        var cause = new InvalidOperationException("sensitive provider details");
        using var client = new GitHubApiClient(http, _ => Task.FromException<string>(cause), s_api);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetJsonAsync("repos/a/b?token=secret", default));
        Assert.Equal("GET /repos/a/b: authentication failed.", error.Message);
        Assert.Same(cause, error.InnerException);
    }
 
    [Fact]
    public async Task CanceledWaiterDoesNotPoisonSharedTokenLookup()
    {
        var tokenCalls = 0;
        var credential = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
        using var handler = new GitHubHttpMessageHandler((_, _) => Task.FromResult(GitHubHttpMessageHandler.Json("{}")));
        using var http = new HttpClient(handler);
        using var client = new GitHubApiClient(
            http,
            _ =>
        {
            tokenCalls++;
            return credential.Task;
        },
            s_api);
        using var cancellation = new CancellationTokenSource();
        var canceled = client.GetJsonAsync("first", cancellation.Token);
        cancellation.Cancel();
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => canceled);
        credential.SetResult("example-token");
        Assert.NotNull(await client.GetJsonAsync("second", default));
        Assert.Equal(1, tokenCalls);
    }
 
    [Theory]
    [InlineData("")]
    [InlineData("example-token\r\nInjected: secret")]
    public async Task InvalidTokensDoNotReachTransport(string token)
    {
        using var handler = new GitHubHttpMessageHandler((_, _) => throw new InvalidOperationException("Must not send."));
        using var http = new HttpClient(handler);
        using var client = new GitHubApiClient(http, _ => Task.FromResult(token), s_api);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetJsonAsync("start", default));
        Assert.Equal("GET /start: authentication failed.", error.Message);
    }
 
    [Theory]
    [InlineData("""<https://elsewhere.invalid/page?secret=value>; rel="next" """, 1)]
    [InlineData("""<https://api.github.com/start>; rel="next" """, 1)]
    [InlineData("""<http://api.github.com/page>; rel="next" """, 1)]
    [InlineData("""not-a-link; rel="next" """, 1)]
    public async Task PaginationRejectsUnsafeOrCyclicLinks(string link, int expectedCalls)
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            return Task.FromResult(GitHubHttpMessageHandler.Json("[]", link));
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetPagesAsync("start", default));
        Assert.StartsWith("GET /start:", error.Message, StringComparison.Ordinal);
        Assert.Equal(expectedCalls, calls);
    }
 
    [Fact]
    public async Task PaginationHasFinitePageBudgetEvenForUniqueLinks()
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            if (calls > 1000)
            {
                throw new InvalidOperationException("Request exceeded the allowed page budget.");
            }
 
            return Task.FromResult(GitHubHttpMessageHandler.Json("[]", $"<https://api.github.com/start?page={calls}>; rel=\"next\""));
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetPagesAsync("start", default));
        Assert.Equal("GET /start: pagination exceeds the 1000-page limit.", error.Message);
        Assert.Equal(1000, calls);
    }
 
    [Fact]
    public async Task PaginationHasAggregateByteBudget()
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            return Task.FromResult(calls == 1
                ? GitHubHttpMessageHandler.Json(
                    new string(' ', 16 * 1024 * 1024 - 2) + "[]",
                    """<https://api.github.com/start?page=2>; rel="next" """)
                : GitHubHttpMessageHandler.Json("[]"));
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetPagesAsync("start", default));
        Assert.Equal("GET /start: response exceeds the 0-byte limit.", error.Message);
        Assert.Equal(2, calls);
    }
 
    [Theory]
    [InlineData(0)]
    [InlineData(7)]
    public async Task PaginationStopsReadingAtRemainingBudgetPlusDetectionByte(int remainingBytes)
    {
        var calls = 0;
        using var secondPage = new GitHubDownloadStream(new string(' ', count: 100) + "[]");
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            return Task.FromResult(calls == 1
                ? GitHubHttpMessageHandler.Json(
                    new string(' ', 16 * 1024 * 1024 - remainingBytes - 2) + "[]",
                    """<https://api.github.com/start?page=2>; rel="next" """)
                : new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(secondPage) });
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        await Assert.ThrowsAsync<GitHubApiException>(() => client.GetPagesAsync("start", default));
        Assert.Equal(remainingBytes + 1, secondPage.BytesRead);
        Assert.Equal(2, calls);
    }
 
    [Theory]
    [InlineData(502)]
    [InlineData(503)]
    [InlineData(504)]
    [InlineData(429)]
    [InlineData(403)]
    public async Task RetryableReadStatusRetriesBoundedly(int status)
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            var response = calls == 1
                ? new HttpResponseMessage((HttpStatusCode)status)
                : GitHubHttpMessageHandler.Json("""{"ok":true}""");
            response.Headers.TryAddWithoutValidation("Retry-After", "0");
            return Task.FromResult(response);
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        Assert.True((await client.GetJsonAsync("start", default))!["ok"]!.GetValue<bool>());
        Assert.Equal(2, calls);
    }
 
    [Fact]
    public async Task RetryBudgetIsFiniteAndStatusIsPreserved()
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            var response = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable);
            response.Headers.TryAddWithoutValidation("Retry-After", "0");
            return Task.FromResult(response);
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetJsonAsync("start?token=sensitive", default));
        Assert.Equal(HttpStatusCode.ServiceUnavailable, error.StatusCode);
        Assert.Equal("GET /start: HTTP 503. [Retry-After=0]", error.Message);
        Assert.Equal(3, calls);
    }
 
    [Fact]
    public async Task RateLimitFailureIncludesNormalizedAllowlistedMetadata()
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            var response = new HttpResponseMessage(HttpStatusCode.Forbidden);
            response.Headers.TryAddWithoutValidation("X-RateLimit-Remaining", "000");
            response.Headers.TryAddWithoutValidation("X-RateLimit-Reset", "009223372036854775807");
            response.Headers.TryAddWithoutValidation("Retry-After", "003600");
            response.Headers.TryAddWithoutValidation("X-GitHub-Request-Id", "abcd:0123:ef45");
            response.Headers.TryAddWithoutValidation("X-Diagnostic", "sensitive freeform value");
            response.Content = new StringContent("sensitive response body");
            return Task.FromResult(response);
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetJsonAsync("start?token=sensitive", default));
        Assert.Equal("GET /start: HTTP 403. [rate limited; X-RateLimit-Remaining=0; X-RateLimit-Reset=9223372036854775807; Retry-After=3600; X-GitHub-Request-Id=ABCD:0123:EF45]", error.Message);
        Assert.Equal(HttpStatusCode.Forbidden, error.StatusCode);
        Assert.Equal(1, calls);
    }
 
    [Fact]
    public async Task PositiveRemainingQuotaDoesNotLabelForbiddenAsRateLimited()
    {
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            var response = new HttpResponseMessage(HttpStatusCode.Forbidden);
            response.Headers.TryAddWithoutValidation("X-RateLimit-Remaining", "04801");
            response.Headers.TryAddWithoutValidation("X-RateLimit-Reset", "01700000000");
            return Task.FromResult(response);
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetJsonAsync("start", default));
        Assert.Equal("GET /start: HTTP 403. [X-RateLimit-Remaining=4801; X-RateLimit-Reset=1700000000]", error.Message);
    }
 
    [Fact]
    public async Task RetryAfterDateIsReportedAsNumericUnixTimeWithoutMutationRetry()
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            var response = new HttpResponseMessage(HttpStatusCode.Forbidden);
            response.Headers.TryAddWithoutValidation("Retry-After", "Sun, 06 Nov 1994 08:49:37 GMT");
            return Task.FromResult(response);
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() =>
            client.SendAsync(HttpMethod.Post, "rerun", body: null, HttpStatusCode.Created, default));
        Assert.Equal("POST /rerun: HTTP 403. [rate limited; Retry-After-Unix=784111777]", error.Message);
        Assert.Equal(1, calls);
    }
 
    [Fact]
    public async Task FutureRetryAfterDateFailsWithoutRetryingBeforeServerDeadline()
    {
        var retryAt = DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 3600);
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            var response = new HttpResponseMessage(HttpStatusCode.Forbidden);
            response.Headers.TryAddWithoutValidation("Retry-After", retryAt.ToString("r", System.Globalization.CultureInfo.InvariantCulture));
            return Task.FromResult(response);
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetJsonAsync("start", default));
        Assert.Equal($"GET /start: HTTP 403. [rate limited; Retry-After-Unix={retryAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture)}]", error.Message);
        Assert.Equal(1, calls);
    }
 
    [Theory]
    [InlineData(33, 2)]
    [InlineData(32, 4)]
    public async Task RequestIdLengthAndGroupBoundsExcludeOversizedValues(int groupSize, int groupCount)
    {
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            var response = new HttpResponseMessage(HttpStatusCode.Forbidden);
            response.Headers.TryAddWithoutValidation("X-GitHub-Request-Id", string.Join(":", Enumerable.Repeat(new string('A', groupSize), groupCount)));
            return Task.FromResult(response);
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetJsonAsync("start", default));
        Assert.Equal("GET /start: HTTP 403.", error.Message);
    }
 
    [Theory]
    [InlineData("-1")]
    [InlineData("https://private.invalid/?signature=secret")]
    [InlineData("1234567890123456789012345678901234567890")]
    [InlineData("12\r\nToken: secret")]
    [InlineData("12, 34")]
    public async Task HostileHeaderValuesNeverEnterDiagnostics(string value)
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            var response = new HttpResponseMessage(HttpStatusCode.Forbidden);
            foreach (var header in new[] { "X-RateLimit-Remaining", "X-RateLimit-Reset", "Retry-After", "X-GitHub-Request-Id" })
            {
                response.Headers.TryAddWithoutValidation(header, value);
            }
 
            response.Content = new StringContent("sensitive response body");
            return Task.FromResult(response);
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetJsonAsync("start?token=sensitive", default));
        Assert.Equal("GET /start: HTTP 403.", error.Message);
        Assert.Equal(1, calls);
    }
 
    [Fact]
    public async Task AmbiguousRepeatedHeaderValuesAreExcluded()
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            var response = new HttpResponseMessage(HttpStatusCode.Forbidden);
            response.Headers.TryAddWithoutValidation("X-RateLimit-Remaining", ["0", "4801"]);
            response.Headers.TryAddWithoutValidation("X-RateLimit-Reset", ["1", "2"]);
            response.Headers.TryAddWithoutValidation("Retry-After", ["0", "3600"]);
            response.Headers.TryAddWithoutValidation("X-GitHub-Request-Id", ["ABCD:1234", "ABCD:5678"]);
            return Task.FromResult(response);
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetJsonAsync("start", default));
        Assert.Equal("GET /start: HTTP 403.", error.Message);
        Assert.Equal(1, calls);
    }
 
    [Theory]
    [InlineData(401)]
    [InlineData(403)]
    public async Task AuthenticationAndOrdinaryForbiddenFailuresDoNotRetry(int status)
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            return Task.FromResult(new HttpResponseMessage((HttpStatusCode)status));
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        await Assert.ThrowsAsync<GitHubApiException>(() => client.GetJsonAsync("start", default));
        Assert.Equal(1, calls);
    }
 
    [Fact]
    public async Task ExhaustedRateLimitResetHeaderAllowsReadRetry()
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            if (calls > 1)
            {
                return Task.FromResult(GitHubHttpMessageHandler.Json("{}"));
            }
 
            var response = new HttpResponseMessage(HttpStatusCode.Forbidden);
            response.Headers.TryAddWithoutValidation("X-RateLimit-Remaining", "0");
            response.Headers.TryAddWithoutValidation("X-RateLimit-Reset", "1");
            return Task.FromResult(response);
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        Assert.NotNull(await client.GetJsonAsync("start", default));
        Assert.Equal(2, calls);
    }
 
    [Fact]
    public async Task CancellationInterruptsRetryAfterDelay()
    {
        using var cancellation = new CancellationTokenSource();
        var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests);
            response.Headers.TryAddWithoutValidation("Retry-After", "5");
            received.SetResult();
            return Task.FromResult(response);
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var pending = client.GetJsonAsync("start", cancellation.Token);
        await received.Task;
        cancellation.Cancel();
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => pending);
        Assert.Equal(1, calls);
    }
 
    [Theory]
    [InlineData("retry-after")]
    [InlineData("reset")]
    [InlineData("unspecified")]
    public async Task RateLimitWaitBeyondBudgetDoesNotRetryPrematurely(string limit)
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            if (calls > 1)
            {
                return Task.FromResult(GitHubHttpMessageHandler.Json("{}"));
            }
 
            var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests);
            if (limit == "retry-after")
            {
                response.Headers.TryAddWithoutValidation("Retry-After", "3600");
            }
            else if (limit == "reset")
            {
                response.Headers.TryAddWithoutValidation("X-RateLimit-Remaining", "0");
                response.Headers.TryAddWithoutValidation("X-RateLimit-Reset", long.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture));
            }
 
            return Task.FromResult(response);
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetJsonAsync("start", default));
        Assert.Equal(HttpStatusCode.TooManyRequests, error.StatusCode);
        Assert.Equal(1, calls);
    }
 
    [Theory]
    [InlineData("POST", false)]
    [InlineData("PATCH", false)]
    [InlineData("PUT", false)]
    [InlineData("DELETE", false)]
    [InlineData("POST", true)]
    public async Task MutationIsNeverReplayedAfterFailure(string method, bool lostResponse)
    {
        var calls = 0;
        var cause = new HttpRequestException("sensitive transport details");
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            return lostResponse ? Task.FromException<HttpResponseMessage>(cause)
                : Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable));
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() =>
            client.SendAsync(new HttpMethod(method), "repos/a/b/rerun", body: null, HttpStatusCode.Created, default));
        Assert.Equal(1, calls);
        if (lostResponse)
        {
            Assert.Same(cause, error.InnerException);
            Assert.Equal($"{method} /repos/a/b/rerun: request failed.", error.Message);
        }
    }
 
    [Fact]
    public async Task MutationsSupportJsonAndEmptyCreatedResponses()
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler(async (request, token) =>
        {
            Assert.Equal(HttpMethod.Post, request.Method);
            Assert.Equal("""{"title":"issue"}""", await request.Content!.ReadAsStringAsync(token));
            calls++;
            return new HttpResponseMessage(HttpStatusCode.Created)
            {
                Content = new StringContent(calls == 1 ? """{"number":42}""" : "")
            };
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var json = await client.SendJsonAsync(HttpMethod.Post, "issues", JsonNode.Parse("""{"title":"issue"}"""), HttpStatusCode.Created, default);
        Assert.Equal(42, json!["number"]!.GetValue<int>());
        await client.SendAsync(HttpMethod.Post, "rerun", JsonNode.Parse("""{"title":"issue"}"""), HttpStatusCode.Created, default);
        Assert.Equal(2, calls);
    }
 
    [Fact]
    public async Task LostMutationBodyIsNotReplayed()
    {
        var calls = 0;
        using var stream = new GitHubDownloadStream("""{"number":""", _ => Task.FromException(new IOException("Connection lost.")));
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Created) { Content = new StreamContent(stream) });
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() =>
            client.SendJsonAsync(HttpMethod.Post, "issues", body: null, HttpStatusCode.Created, default));
        Assert.Equal("POST /issues: response body could not be read completely.", error.Message);
        Assert.IsType<IOException>(error.InnerException);
        Assert.Equal(1, calls);
    }
 
    [Fact]
    public async Task DownloadRedirectDoesNotForwardApiHeadersOrAuthorization()
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((request, _) =>
        {
            calls++;
            if (calls == 1)
            {
                Assert.NotNull(request.Headers.Authorization);
                var redirect = new HttpResponseMessage(HttpStatusCode.Found);
                redirect.Headers.Location = new Uri("https://blob.invalid/log?signature=secret");
                return Task.FromResult(redirect);
            }
 
            Assert.Null(request.Headers.Authorization);
            Assert.Empty(request.Headers.Accept);
            Assert.False(request.Headers.Contains("X-GitHub-Api-Version"));
            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("logs") });
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var result = await client.DownloadTextAsync("logs", maximumBytes: 100, default);
        Assert.Equal("logs", result.Text);
        Assert.Null(result.Error);
        Assert.Equal(2, calls);
    }
 
    [Fact]
    public async Task SignedRedirectUrlNeverAppearsInErrors()
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            var response = new HttpResponseMessage(calls == 1 ? HttpStatusCode.Found : HttpStatusCode.Forbidden);
            response.Headers.Location = new Uri("https://blob.invalid/private?signature=secret");
            response.Content = new StringContent("secret response");
            return Task.FromResult(response);
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var result = await client.DownloadTextAsync("repos/a/b/logs?token=secret", maximumBytes: 100, default);
        Assert.Equal("", result.Text);
        Assert.Equal("GET /repos/a/b/logs: HTTP 403.", result.Error);
    }
 
    [Theory]
    [InlineData("http://blob.invalid/log")]
    [InlineData("https://api.github.com/logs")]
    [InlineData("https://user:password@blob.invalid/log")]
    public async Task DownloadsRejectInsecureCredentialedOrCyclicRedirects(string target)
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            var response = new HttpResponseMessage(HttpStatusCode.Found);
            response.Headers.Location = new Uri(target);
            return Task.FromResult(response);
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var result = await client.DownloadTextAsync("logs", maximumBytes: 100, default);
        Assert.Equal("", result.Text);
        Assert.Equal("GET /logs: invalid or cyclic redirect.", result.Error);
        Assert.Equal(1, calls);
    }
 
    [Fact]
    public async Task NonCyclicRedirectChainHasFiniteBudget()
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            var response = new HttpResponseMessage(HttpStatusCode.Found);
            response.Headers.Location = new Uri($"https://blob.invalid/log/{calls}?signature=secret");
            return Task.FromResult(response);
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var result = await client.DownloadTextAsync("logs", maximumBytes: 100, default);
        Assert.Equal("GET /logs: invalid or cyclic redirect.", result.Error);
        Assert.Equal(6, calls);
    }
 
    [Fact]
    public async Task DefaultHeadersAreRejectedBeforeTheyCanLeakAcrossOrigins()
    {
        using var handler = new GitHubHttpMessageHandler((_, _) => throw new InvalidOperationException("Must not send."));
        using var http = new HttpClient(handler);
        http.DefaultRequestHeaders.Add("Cookie", "sensitive=value");
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetJsonAsync("start", default));
        Assert.Equal("GET /start: the HTTP client must not have default request headers.", error.Message);
    }
 
    [Fact]
    public async Task JsonDoesNotFollowOffOriginRedirects()
    {
        var calls = 0;
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            var response = new HttpResponseMessage(HttpStatusCode.Found);
            response.Headers.Location = new Uri("https://blob.invalid/private?signature=secret");
            return Task.FromResult(response);
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetJsonAsync("logs", default));
        Assert.Equal("GET /logs: invalid or cyclic redirect.", error.Message);
        Assert.Equal(1, calls);
    }
 
    [Fact]
    public async Task TextDownloadRetainsPartialBytesWithoutLeakingStreamError()
    {
        var calls = 0;
        var cause = new IOException("https://blob.invalid/private?signature=secret");
        using var stream = new GitHubDownloadStream("partial logs", _ => Task.FromException(cause));
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) });
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var result = await client.DownloadTextAsync("logs", maximumBytes: 100, default);
        Assert.Equal("partial logs", result.Text);
        Assert.Equal("GET /logs: response body could not be read completely.", result.Error);
        Assert.Equal(1, calls);
    }
 
    [Fact]
    public async Task ChunkedTextDownloadStopsAtByteLimit()
    {
        using var stream = new GitHubDownloadStream("0123456789");
        using var handler = new GitHubHttpMessageHandler((_, _) =>
            Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) }));
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var result = await client.DownloadTextAsync("logs", maximumBytes: 5, default);
        Assert.Equal("01234", result.Text);
        Assert.Equal("GET /logs: response exceeds the 5-byte limit.", result.Error);
        Assert.Equal(6, stream.BytesRead);
    }
 
    [Fact]
    public async Task ChunkedFileDownloadEnforcesActualByteLimit()
    {
        var directory = Directory.CreateDirectory(Path.Combine(RepoRoot.Path, "artifacts", "github-http-tests", Guid.NewGuid().ToString("N")));
        var path = Path.Combine(directory.FullName, "artifact.zip");
        try
        {
            using var stream = new GitHubDownloadStream("0123456789");
            using var handler = new GitHubHttpMessageHandler((_, _) =>
                Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) }));
            using var http = new HttpClient(handler);
            using var client = Create(http);
            var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.DownloadFileAsync("artifact", path, maximumBytes: 5, default));
            Assert.Equal("GET /artifact: response exceeds the 5-byte limit.", error.Message);
            Assert.Equal(6, stream.BytesRead);
            Assert.Equal("01234", await File.ReadAllTextAsync(path));
        }
        finally
        {
            directory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task FileDownloadReturnsActualCountAndDoesNotClobberExistingOutput()
    {
        var directory = Directory.CreateDirectory(Path.Combine(RepoRoot.Path, "artifacts", "github-http-tests", Guid.NewGuid().ToString("N")));
        var path = Path.Combine(directory.FullName, "artifact.zip");
        try
        {
            using var handler = new GitHubHttpMessageHandler((_, _) =>
                Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("artifact") }));
            using var http = new HttpClient(handler);
            using var client = Create(http);
            Assert.Equal(8, await client.DownloadFileAsync("artifact", path, maximumBytes: 100, default));
            Assert.Equal("artifact", await File.ReadAllTextAsync(path));
            await Assert.ThrowsAsync<GitHubApiException>(() => client.DownloadFileAsync("artifact", path, maximumBytes: 100, default));
            Assert.Equal("artifact", await File.ReadAllTextAsync(path));
        }
        finally
        {
            directory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task FileDownloadCancellationRetainsBoundedIncompleteOutput()
    {
        var directory = Directory.CreateDirectory(Path.Combine(RepoRoot.Path, "artifacts", "github-http-tests", Guid.NewGuid().ToString("N")));
        var path = Path.Combine(directory.FullName, "artifact.zip");
        try
        {
            using var cancellation = new CancellationTokenSource();
            using var stream = new GitHubDownloadStream(
                "partial",
                async token =>
            {
                cancellation.Cancel();
                await Task.Delay(Timeout.InfiniteTimeSpan, token);
            });
            using var handler = new GitHubHttpMessageHandler((_, _) =>
                Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) }));
            using var http = new HttpClient(handler);
            using var client = Create(http);
            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => client.DownloadFileAsync("artifact", path, maximumBytes: 100, cancellation.Token));
            Assert.Equal("partial", await File.ReadAllTextAsync(path));
        }
        finally
        {
            directory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task CallerCancellationWhileReadingBodyPropagates()
    {
        using var cancellation = new CancellationTokenSource();
        using var stream = new GitHubDownloadStream(
            "partial",
            async token =>
        {
            cancellation.Cancel();
            await Task.Delay(Timeout.InfiniteTimeSpan, token);
        });
        using var handler = new GitHubHttpMessageHandler((_, _) =>
            Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) }));
        using var http = new HttpClient(handler);
        using var client = Create(http);
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => client.DownloadTextAsync("logs", maximumBytes: 100, cancellation.Token));
    }
 
    [Fact]
    public async Task CallerCancellationWhileReadingJsonPropagatesWithoutRetry()
    {
        var calls = 0;
        using var cancellation = new CancellationTokenSource();
        using var stream = new GitHubDownloadStream(
            "{",
            async token =>
        {
            cancellation.Cancel();
            await Task.Delay(Timeout.InfiniteTimeSpan, token);
        });
        using var handler = new GitHubHttpMessageHandler((_, _) =>
        {
            calls++;
            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) });
        });
        using var http = new HttpClient(handler);
        using var client = Create(http);
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => client.GetJsonAsync("start", cancellation.Token));
        Assert.Equal(1, calls);
    }
 
    [Fact]
    public async Task JsonRejectsOversizedChunkedBody()
    {
        using var stream = new GitHubDownloadStream(new string(' ', 16 * 1024 * 1024 + 1));
        using var handler = new GitHubHttpMessageHandler((_, _) =>
            Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) }));
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetJsonAsync("large", default));
        Assert.Equal("GET /large: response exceeds the 16777216-byte limit.", error.Message);
    }
 
    [Fact]
    public async Task InvalidJsonPreservesCauseButNotResponseBody()
    {
        using var handler = new GitHubHttpMessageHandler((_, _) => Task.FromResult(GitHubHttpMessageHandler.Json("secret invalid json")));
        using var http = new HttpClient(handler);
        using var client = Create(http);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetJsonAsync("invalid?signature=secret", default));
        Assert.Equal("GET /invalid: invalid JSON response.", error.Message);
        Assert.IsAssignableFrom<System.Text.Json.JsonException>(error.InnerException);
    }
 
    [Theory]
    [InlineData("https://elsewhere.invalid/private?secret=value")]
    [InlineData("//elsewhere.invalid/private")]
    public async Task OffOriginEndpointsAreRejectedBeforeResolvingToken(string endpoint)
    {
        using var handler = new GitHubHttpMessageHandler((_, _) => throw new InvalidOperationException("Must not send."));
        using var http = new HttpClient(handler);
        using var client = new GitHubApiClient(http, _ => throw new InvalidOperationException("Must not authenticate."), s_api);
        var error = await Assert.ThrowsAsync<GitHubApiException>(() => client.GetJsonAsync(endpoint, default));
        Assert.Equal("GET: endpoint must use the configured HTTPS API origin.", error.Message);
    }
 
    private static GitHubApiClient Create(HttpClient http) => new(http, _ => Task.FromResult("example-token"), s_api);
}