using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using PrivaPub.Infrastructure.Http; namespace PrivaPub.Tests.Infrastructure { public sealed class FederationHttpTests : IAsyncLifetime { WebApplication _peer; string _base; public async ValueTask InitializeAsync() { var builder = WebApplication.CreateSlimBuilder(); builder.WebHost.UseUrls("http://127.0.0.1:0"); _peer = builder.Build(); _peer.MapGet("/actor", () => Results.Text("{\"id\":\"x\"}", "application/activity+json")); _peer.MapGet("/html", () => Results.Text("", "text/html")); _peer.MapGet("/big", () => Results.Text("{\"a\":\"" + new string('a', FederationHttp.MaxResponseBytes) + "\"}", "application/activity+json")); _peer.MapGet("/hop/{n:int}", (int n) => Results.Redirect(n == 0 ? "/actor" : $"/hop/{n - 1}")); _peer.MapGet("/gone", () => Results.StatusCode(410)); await _peer.StartAsync(); _base = _peer.Urls.First(); } public async ValueTask DisposeAsync() => await _peer.DisposeAsync(); static FederationHttp Client(bool allowTestNetwork = true) { var options = new FederationOptions { AllowPrivateNetworks = allowTestNetwork, AllowPlainHttp = allowTestNetwork }; var services = new ServiceCollection(); services.AddHttpClient(FederationHttp.ClientName) .ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options)); var provider = services.BuildServiceProvider(); return new FederationHttp(provider.GetRequiredService(), new MemoryCache(new MemoryCacheOptions()), new StaticOptionsMonitor(options), NullLogger.Instance); } [Fact] public async Task Reads_a_json_document() { using var fetched = await Client().GetJson($"{_base}/actor", "application/activity+json", default, TestContext.Current.CancellationToken); Assert.NotNull(fetched); Assert.Equal("x", fetched.Root.GetProperty("id").GetString()); } [Fact] public async Task Follows_up_to_three_redirects_and_reports_the_final_url() { using var fetched = await Client().GetJson($"{_base}/hop/2", "application/activity+json", default, TestContext.Current.CancellationToken); Assert.NotNull(fetched); Assert.Equal($"{_base}/actor", fetched.FinalUri.ToString()); } [Fact] public async Task Refuses_a_fourth_redirect() => Assert.Null(await Client().GetJson($"{_base}/hop/3", "application/activity+json", default, TestContext.Current.CancellationToken)); [Fact] public async Task Refuses_html() => Assert.Null(await Client().GetJson($"{_base}/html", "application/activity+json", default, TestContext.Current.CancellationToken)); [Fact] public async Task Refuses_a_body_over_the_limit() => Assert.Null(await Client().GetJson($"{_base}/big", "application/activity+json", default, TestContext.Current.CancellationToken)); [Fact] public async Task Refuses_an_error_status() => Assert.Null(await Client().GetJson($"{_base}/gone", "application/activity+json", default, TestContext.Current.CancellationToken)); [Fact] public async Task Refuses_a_private_network_in_production_mode() => Assert.Null(await Client(allowTestNetwork: false).GetJson($"{_base}/actor", "application/activity+json", default, TestContext.Current.CancellationToken)); [Theory] [InlineData("https://mastodon.social/users/Gargron", true)] [InlineData("http://mastodon.social/users/Gargron", false)] [InlineData("https://user:pass@mastodon.social/", false)] [InlineData("https://localhost/", false)] [InlineData("https://printer.local/", false)] [InlineData("https://metadata.google.internal/", false)] [InlineData("https://127.0.0.1/", false)] [InlineData("https://[::1]/", false)] [InlineData("ftp://example.org/", false)] public void IsAllowed_takes_https_dns_names_only(string url, bool allowed) => Assert.Equal(allowed, Client(allowTestNetwork: false).IsAllowed(new Uri(url))); sealed class StaticOptionsMonitor : IOptionsMonitor { public StaticOptionsMonitor(FederationOptions value) => CurrentValue = value; public FederationOptions CurrentValue { get; } public FederationOptions Get(string name) => CurrentValue; public IDisposable OnChange(Action listener) => default; } } }