using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.Extensions.DependencyInjection; using MongoDB.Entities; using PrivaPub.ClientModels.Social; using PrivaPub.Domain.Social; using PrivaPub.Domain.Statuses; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; using System.Net; using System.Text; using System.Text.Json.Nodes; using System.Text.RegularExpressions; using static PrivaPub.Tests.Support.Host.FederationHelpers; namespace PrivaPub.Tests.Http { [Trait("Category", "Integration")] public sealed partial class PersonaSeparationHttpTests : IAsyncLifetime { const string Base = PrivaPubHost.Base; const int MaxDocuments = 300; PrivaPubHost _host; Root _root; Persona _alpha; Persona _bravo; Persona _charlie; LocalGroup _community; string _tag; Post _shown; Post _poll; List _alphaPosts; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); var token = TestContext.Current.CancellationToken; _host = await PrivaPubHost.Shared(); _root = await _host.SignUp("sep"); _alpha = await _host.Persona(_root, "sepalpha"); _bravo = await _host.Persona(_root, "sepbravo"); _charlie = await _host.Persona(await _host.SignUp(), "sepcharlie"); _community = await _host.FederatedGroup(_bravo, community: true, "sepcommunity"); _tag = $"septag{Guid.NewGuid():N}"[..14]; await _host.Publish(_bravo, "the sibling's own words", PostVisibility.FollowersOnly); var follows = _host.Get(); Assert.True((await follows.Follow(_root.Id, new FollowForm { AvatarId = _bravo.Id, Target = _charlie.UserName }, token)).IsValid); _shown = await _host.Publish(_alpha, $"hello from alpha #{_tag}"); _poll = await _host.Publish(_alpha, new StatusDraft { Text = "which one?", PlainText = true, Poll = new PollDraft(new[] { "this", "that" }, 3600, false, false) }); var charlieSays = await _host.Publish(_charlie, "worth sharing"); _alphaPosts = new List { _shown, _poll, await _host.Publish(_alpha, "quietly", PostVisibility.Unlisted), await _host.Publish(_alpha, "for my followers", PostVisibility.FollowersOnly), await _host.Publish(_alpha, $"@{_charlie.UserName} between us", PostVisibility.Direct), await _host.PublishLocated(_alpha, "around here"), await _host.Publish(_alpha, new StatusDraft { Text = "quoting charlie", PlainText = true, QuotedStatusId = charlieSays.ID }), await _host.Reblog(_alpha, charlieSays) }; await DB.Default.SaveAsync(new Pin { AvatarId = _alpha.Id, PostId = _shown.ID }, token); Assert.True((await follows.Follow(_root.Id, new FollowForm { AvatarId = _alpha.Id, Target = _charlie.UserName }, token)).IsValid); Assert.True((await follows.Follow(_charlie.Root.Id, new FollowForm { AvatarId = _charlie.Id, Target = _alpha.UserName }, token)).IsValid); Assert.True((await _host.Get().Favourite(await _host.Actor(_charlie), _shown.ID, true, token)).Ok); await _host.Publish(_charlie, new StatusDraft { Text = $"@{_alpha.UserName} nice", PlainText = true, InReplyTo = _shown.ID }); } public ValueTask DisposeAsync() => ValueTask.CompletedTask; string[] Forbidden() => new[] { _bravo.Id, _bravo.UserName, _root.Id, _root.UserName, _community.Id, _community.UserName }; [Fact] public async Task Nothing_the_api_tells_a_persona_names_its_sibling_or_its_login() { var bearer = await _host.MastodonToken(_alpha); using var client = _host.As(bearer); var notifications = await client.Fetch("/api/v1/notifications", "application/json"); Assert.Equal(HttpStatusCode.OK, notifications.Status); var notificationIds = JsonNode.Parse(notifications.Text)!.AsArray().Select(n => n!["id"]!.GetValue()).ToList(); Assert.NotEmpty(notificationIds); var paths = Routes().Select(Fill).Concat(new[] { $"/api/v2/search?q={_alpha.UserName}", $"/api/v2/search?q={Uri.EscapeDataString($"@{_alpha.UserName}@{PrivaPubHost.Host}")}", $"/api/v2/search?q={Uri.EscapeDataString(_alpha.ActorUri())}", $"/api/v2/search?q={Uri.EscapeDataString(_shown.ObjectURI)}", $"/api/v2/search?q=%23{_tag}", $"/api/v1/accounts/search?q={_alpha.UserName}", $"/api/v1/accounts/lookup?acct={_alpha.UserName}", $"/api/v1/accounts/relationships?id[]={_alpha.Id}&id[]={_charlie.Id}", $"/api/v1/accounts/familiar_followers?id[]={_charlie.Id}", $"/api/v1/accounts/{_alpha.Id}/statuses?pinned=true", $"/api/v1/accounts/{_alpha.Id}/statuses?exclude_replies=true&limit=40", $"/api/v1/statuses?{string.Join("&", _alphaPosts.Select(p => "id[]=" + p.ID))}", $"/api/v1/statuses/{_poll.ID}", $"/api/v1/polls/{_poll.ID}", $"/api/v1/notifications/{notificationIds[0]}", "/api/v1/timelines/home?limit=40", "/api/v1/timelines/public?local=true&limit=40" }).Concat(_alphaPosts.Select(p => $"/api/v1/statuses/{p.ID}/context")) .Distinct() .ToList(); var answered = 0; var failures = new List(); foreach (var path in paths) { var fetched = await client.Fetch(path, "application/json"); if ((int)fetched.Status >= 500) failures.Add($"{path} answered {(int)fetched.Status}"); if (fetched.Status == HttpStatusCode.OK) answered++; foreach (var secret in Forbidden()) if (fetched.Text.Contains(secret, StringComparison.OrdinalIgnoreCase) || fetched.Response.Headers.ToString().Contains(secret, StringComparison.OrdinalIgnoreCase)) failures.Add($"{path} names {secret}: {Short(fetched.Text)}"); } Assert.True(failures.Count == 0, string.Join("\n", failures)); Assert.True(answered >= 40, $"only {answered} of {paths.Count} answers were 200"); var me = await client.Fetch("/api/v1/accounts/verify_credentials", "application/json"); Assert.Contains(_alpha.Id, me.Text); Assert.Contains(_shown.ID, (await client.Fetch("/api/v1/timelines/home?limit=40", "application/json")).Text); } [Fact] public async Task Nothing_published_about_a_persona_names_its_sibling_or_its_login() { using var client = _host.Client(); var seeds = new[] { $"/peasants/{_alpha.UserName}", $"/users/{_alpha.UserName}", $"/peasants/{_alpha.UserName}/anus?page=true", $"/peasants/{_alpha.UserName}/scribbles/{_shown.ID}", $"/peasants/{_alpha.UserName}/grunts/create-{_shown.ID}", $"/.well-known/webfinger?resource=acct:{_alpha.UserName}@{PrivaPubHost.Host}", "/.well-known/nodeinfo", $"/@{_alpha.UserName}", $"/@{_alpha.UserName}/{_shown.ID}" }; var queue = new Queue(seeds); var seen = new HashSet(seeds); var failures = new List(); var fetchedOk = 0; while (queue.Count > 0 && seen.Count <= MaxDocuments) { var path = queue.Dequeue(); var html = path.StartsWith("/@", StringComparison.Ordinal); var fetched = await client.Fetch(path, html ? Browser : path.StartsWith("/peasants/", StringComparison.Ordinal) ? ActivityJson : "application/json"); if ((int)fetched.Status >= 500) failures.Add($"{path} answered {(int)fetched.Status}"); if (fetched.Status == HttpStatusCode.OK) fetchedOk++; foreach (var secret in Forbidden()) if (fetched.Text.Contains(secret, StringComparison.OrdinalIgnoreCase)) failures.Add($"{path} names {secret}: {Short(fetched.Text)}"); var links = Links(fetched.Text).ToList(); if (fetched.Location != default) links.Add(fetched.Location.StartsWith('/') ? Base + fetched.Location : fetched.Location); foreach (var link in links) { if (!link.StartsWith(Base + "/", StringComparison.Ordinal)) continue; var next = PathOf(link.Split('#')[0]); if (Followed(next) && seen.Add(next)) queue.Enqueue(next); } } Assert.True(failures.Count == 0, string.Join("\n", failures)); Assert.Contains($"/peasants/{_alpha.UserName}/groupies", seen); Assert.Contains($"/peasants/{_alpha.UserName}/trophies", seen); Assert.Contains("/nodeinfo/2.1", seen); Assert.True(fetchedOk >= 15, $"only {fetchedOk} of {seen.Count} documents were served"); } bool Followed(string path) => path.StartsWith($"/peasants/{_alpha.UserName}", StringComparison.Ordinal) || path.StartsWith($"/@{_alpha.UserName}", StringComparison.Ordinal) || path.StartsWith("/.well-known/", StringComparison.Ordinal) || path.StartsWith("/nodeinfo/", StringComparison.Ordinal); static IEnumerable Links(string text) { JsonNode json = default; try { json = JsonNode.Parse(text); } catch (System.Text.Json.JsonException) { } if (json != default) return Strings(json); return Href().Matches(text).Select(m => WebUtility.HtmlDecode(m.Groups[1].Value)); } IEnumerable Routes() { var endpoints = _host.Services.GetRequiredService().Endpoints.OfType(); foreach (var endpoint in endpoints) { var methods = endpoint.Metadata.GetMetadata()?.HttpMethods; var template = "/" + endpoint.RoutePattern.RawText?.TrimStart('/'); if (template.StartsWith("/api/", StringComparison.Ordinal) && methods is { } verbs && verbs.Contains("GET")) yield return template; } } string Fill(string template) { var builder = new StringBuilder(); foreach (var segment in RoutePatternFactory.Parse(template).PathSegments) { builder.Append('/'); foreach (var part in segment.Parts) builder.Append(part switch { RoutePatternLiteralPart literal => literal.Content, RoutePatternSeparatorPart separator => separator.Content, RoutePatternParameterPart parameter => Value(template, parameter.Name), _ => string.Empty }); } return builder.ToString(); } string Value(string template, string parameter) => parameter switch { "hashtag" => _tag, "host" => PrivaPubHost.Host, _ when template.StartsWith("/api/v1/statuses/", StringComparison.Ordinal) || template.Contains("/statuses/{id}", StringComparison.Ordinal) => _shown.ID, _ when template.StartsWith("/api/v1/polls/", StringComparison.Ordinal) => _poll.ID, _ => _alpha.Id }; static string Short(string text) => text.Length > 400 ? text[..400] + "…" : text; [GeneratedRegex("(?:href|src)=\"([^\"]+)\"")] private static partial Regex Href(); } }