using MongoDB.Entities; using PrivaPub.Models.Jobs; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; using System.Globalization; using System.Net; using System.Text; using System.Text.Json.Nodes; namespace PrivaPub.Tests.Http { [Trait("Category", "Integration")] public sealed class InboxRouteTests : IAsyncLifetime { PrivaPubHost _host; HttpClient _client; Peer _peer; Persona _persona; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _host = await PrivaPubHost.Shared(); _client = _host.Client(); _peer = await Peer.Start(); _persona = await _host.Persona(await _host.SignUp(), "inbox"); } public async ValueTask DisposeAsync() { _client?.Dispose(); if (_peer != default) await _peer.DisposeAsync(); } public static TheoryData Inboxes() => new() { "personal", "personal-shared", "shared" }; string Route(string inbox) => inbox switch { "personal" => $"/peasants/{_persona.UserName}/mouth", "personal-shared" => $"/peasants/{_persona.UserName}/human-centipede", _ => "/human-centipede" }; JsonObject Direct(RemoteActor sender) { var noteId = $"{new Uri(sender.Id).GetLeftPart(UriPartial.Authority)}/notes/{Guid.NewGuid():N}"; var to = new JsonArray($"{PrivaPubHost.Base}/peasants/{_persona.UserName}"); return new JsonObject { ["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = sender.Id, ["to"] = to.DeepClone(), ["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = sender.Id, ["to"] = to.DeepClone(), ["content"] = "

knock knock

", ["published"] = DateTime.UtcNow.ToString("O") } }; } static string Id(JsonObject activity) => activity["id"]!.GetValue(); async Task Queued(JsonObject activity) => await DB.Default.Find().Match(j => j.DedupeKey == "inbox|" + Id(activity)).ExecuteAnyAsync(TestContext.Current.CancellationToken); async Task Send(HttpRequestMessage request) => await _client.SendAsync(request, TestContext.Current.CancellationToken); [Theory] [MemberData(nameof(Inboxes))] public async Task A_signed_delivery_is_accepted_and_queued(string inbox) { var sender = new RemoteActor(_peer, "sender"); var activity = Direct(sender); var response = await Send(sender.SignedPost(Route(inbox), activity)); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); Assert.Equal(1, await _host.RunInbox(Id(activity), TestContext.Current.CancellationToken)); } [Theory] [MemberData(nameof(Inboxes))] public async Task Junk_is_400_and_an_unsigned_activity_401(string inbox) { var sender = new RemoteActor(_peer, "unsigned"); foreach (var junk in new[] { "not json", "[1,2,3]", "\"Create\"", "{}", "{\"type\":\"Create\"}" }) { using var request = new HttpRequestMessage(HttpMethod.Post, Route(inbox)) { Content = new StringContent(junk, Encoding.UTF8, "application/activity+json") }; Assert.Equal(HttpStatusCode.BadRequest, (await Send(request)).StatusCode); } var activity = Direct(sender); using var unsigned = new HttpRequestMessage(HttpMethod.Post, Route(inbox)) { Content = new StringContent(activity.ToJsonString(), Encoding.UTF8, "application/activity+json") }; Assert.Equal(HttpStatusCode.Unauthorized, (await Send(unsigned)).StatusCode); Assert.False(await Queued(activity)); } [Fact] public async Task An_unknown_persona_has_no_mouth() { var sender = new RemoteActor(_peer, "lost"); var activity = Direct(sender); var response = await Send(sender.SignedPost($"/peasants/nobody{Guid.NewGuid():N}"[..28] + "/mouth", activity)); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.False(await Queued(activity)); } [Theory] [MemberData(nameof(Inboxes))] public async Task A_tampered_stale_or_misdirected_signature_is_401(string inbox) { var sender = new RemoteActor(_peer, "forger"); var path = Route(inbox); var tampered = Direct(sender); var badDigest = sender.SignedPost(path, tampered); badDigest.Headers.Remove("Digest"); badDigest.Headers.TryAddWithoutValidation("Digest", "SHA-256=" + Convert.ToBase64String(new byte[32])); var stale = Direct(sender); var twoHoursAgo = DateTimeOffset.UtcNow.AddHours(-2).ToString("r", CultureInfo.InvariantCulture); var elsewhere = Direct(sender); var otherBody = Direct(sender); var swapped = sender.SignedPost(path, Direct(sender)); swapped.Content = new StringContent(otherBody.ToJsonString(), Encoding.UTF8, "application/activity+json"); Assert.Equal(HttpStatusCode.Unauthorized, (await Send(badDigest)).StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, (await Send(sender.SignedPost(path, stale, date: twoHoursAgo))).StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, (await Send(sender.SignedPost(path, elsewhere, host: "elsewhere.example"))).StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, (await Send(swapped)).StatusCode); foreach (var activity in new[] { tampered, stale, elsewhere, otherBody }) Assert.False(await Queued(activity)); } [Fact] public async Task Ld_json_with_the_activitystreams_profile_is_accepted() { var sender = new RemoteActor(_peer, "ldjson"); var activity = Direct(sender); var request = sender.SignedPost($"/peasants/{_persona.UserName}/mouth", activity); request.Content!.Headers.Remove("Content-Type"); request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""); var response = await Send(request); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); Assert.True(await Queued(activity)); } [Fact] public async Task An_unreachable_signing_key_asks_the_sender_to_retry() { var sender = new RemoteActor(_peer, "flaky"); _peer.Answer(new Uri(sender.Id).AbsolutePath, 503); var activity = Direct(sender); var response = await Send(sender.SignedPost($"/peasants/{_persona.UserName}/mouth", activity)); Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); Assert.True(response.Headers.RetryAfter?.Delta is { TotalSeconds: > 0 }, "no Retry-After"); Assert.False(await Queued(activity)); } [Fact] public async Task Unsigned_posts_from_one_address_are_limited() { var token = TestContext.Current.CancellationToken; using var client = _host.Client(); client.DefaultRequestHeaders.Remove(PrivaPubHost.ClientHeader); client.DefaultRequestHeaders.Add(PrivaPubHost.ClientHeader, $"fd7e:{Random.Shared.Next(0x10000):x}:{Random.Shared.Next(0x10000):x}::1"); var body = Direct(new RemoteActor(_peer, "flood")).ToJsonString(); async Task Post() { using var content = new StringContent(body, Encoding.UTF8, "application/activity+json"); using var response = await client.PostAsync("/human-centipede", content, token); return response.StatusCode; } var clock = System.Diagnostics.Stopwatch.StartNew(); var first = new List(); for (var i = 0; i < 300; i++) first.Add(await Post()); var limited = await Post(); // The bucket refills 50 every ten seconds: a slow machine may have earned a few more before the limit bites. for (var refilled = (int)((clock.Elapsed.TotalSeconds + 1) / 10) * 50; limited != HttpStatusCode.TooManyRequests && refilled > 0; refilled--) limited = await Post(); Assert.DoesNotContain(HttpStatusCode.TooManyRequests, first); Assert.All(first, status => Assert.Equal(HttpStatusCode.Unauthorized, status)); Assert.Equal(HttpStatusCode.TooManyRequests, limited); var polite = new RemoteActor(_peer, "polite"); using var signed = await client.SendAsync(polite.SignedPost("/human-centipede", Direct(polite)), token); Assert.Equal(HttpStatusCode.Accepted, signed.StatusCode); } } }