using Microsoft.Extensions.DependencyInjection; using MongoDB.Entities; using PrivaPub.Domain.Statuses; using PrivaPub.Federation.Actors; using PrivaPub.Models.Federation; using PrivaPub.Models.Group; using PrivaPub.Models.Post; using PrivaPub.Models.User; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; using System.Net; using GroupEntity = PrivaPub.Models.Group.Group; namespace PrivaPub.Tests.Web { [Trait("Category", "Integration")] public sealed class PublicPagesTests : IAsyncLifetime { const string Csp = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; PrivaPubHost _host; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _host = await PrivaPubHost.Shared(); } public ValueTask DisposeAsync() => ValueTask.CompletedTask; async Task Author(string name = "author") { var persona = await _host.Persona(await _host.SignUp(), name); return await _host.Get().FindById(LocalActorKind.Person, persona.Id, TestContext.Current.CancellationToken); } async Task Publish(LocalActor author, string text, PostVisibility visibility = PostVisibility.Public, string groupId = default) { using var scope = _host.Services.CreateScope(); var outcome = await scope.ServiceProvider.GetRequiredService() .Publish(author, new StatusDraft { Text = text, Visibility = visibility, GroupId = groupId }, TestContext.Current.CancellationToken); Assert.True(outcome.Ok, outcome.Error); return outcome.Post; } async Task Seeded(LocalActor author, string text, PostVisibility visibility) { var post = new Post { GroupUserId = author.Id, AuthorAccountId = author.Id, ActorURI = author.Uri, Text = text, Visibility = visibility }; post.ID = (string)post.GenerateNewID(); post.ObjectURI = author.PostUri(post.ID); await DB.Default.SaveAsync(post, TestContext.Current.CancellationToken); return post; } async Task<(HttpResponseMessage Response, string Body)> Page(string path, string accept = "text/html") { using var client = _host.Client(); using var request = new HttpRequestMessage(HttpMethod.Get, path); request.Headers.Accept.ParseAdd(accept); var response = await client.SendAsync(request, TestContext.Current.CancellationToken); return (response, await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); } static void Hardened(HttpResponseMessage response) { Assert.Equal(Csp, Assert.Single(response.Headers.GetValues("Content-Security-Policy"))); Assert.Equal("no-referrer", Assert.Single(response.Headers.GetValues("Referrer-Policy"))); Assert.Equal("nosniff", Assert.Single(response.Headers.GetValues("X-Content-Type-Options"))); } [Fact] public async Task A_profile_shows_public_and_unlisted_posts_only_behind_hardened_headers() { var token = TestContext.Current.CancellationToken; var author = await Author(); await Publish(author, "for everyone"); await Publish(author, "unlisted but linkable", PostVisibility.Unlisted); await Publish(author, "for followers", PostVisibility.FollowersOnly); await Seeded(author, "for one person", PostVisibility.Direct); await Seeded(author, "for the neighbourhood", PostVisibility.LocalGeo); var removed = await Publish(author, "taken back"); using (var scope = _host.Services.CreateScope()) Assert.True((await scope.ServiceProvider.GetRequiredService().Remove(author, removed.ID, token)).Ok); var (response, body) = await Page($"/@{author.UserName}"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Hardened(response); Assert.Contains("for everyone", body); Assert.Contains("unlisted but linkable", body); Assert.DoesNotContain("for followers", body); Assert.DoesNotContain("for one person", body); Assert.DoesNotContain("for the neighbourhood", body); Assert.DoesNotContain("taken back", body); Assert.Contains("", body); Assert.Contains($"", body); } [Fact] public async Task A_post_page_serves_public_and_unlisted_posts_and_answers_404_for_everything_else() { var token = TestContext.Current.CancellationToken; var author = await Author(); var other = await Author("other"); var open = await Publish(author, "an open post"); var unlisted = await Publish(author, "an unlisted post", PostVisibility.Unlisted); var followersOnly = await Publish(author, "a followers post", PostVisibility.FollowersOnly); var direct = await Seeded(author, "a direct post", PostVisibility.Direct); var located = await Seeded(author, "a located post", PostVisibility.LocalGeo); var removed = await Publish(author, "a removed post"); using (var scope = _host.Services.CreateScope()) Assert.True((await scope.ServiceProvider.GetRequiredService().Remove(author, removed.ID, token)).Ok); var (shown, body) = await Page($"/@{author.UserName}/{open.ID}"); Assert.Equal(HttpStatusCode.OK, shown.StatusCode); Hardened(shown); Assert.Contains("an open post", body); Assert.Contains($"href=\"{author.PostUri(open.ID)}\"", body); Assert.Equal(HttpStatusCode.OK, (await Page($"/@{author.UserName}/{unlisted.ID}")).Response.StatusCode); foreach (var path in new[] { $"/@{author.UserName}/{followersOnly.ID}", $"/@{author.UserName}/{direct.ID}", $"/@{author.UserName}/{located.ID}", $"/@{author.UserName}/{removed.ID}", $"/@{other.UserName}/{open.ID}", $"/@{author.UserName}/not-a-post-id", $"/@{author.UserName}/ffffffffffffffffffffffff", $"/@nobody{Guid.NewGuid():N}"[..20] + $"/{open.ID}" }) { var (response, missing) = await Page(path); Assert.True(response.StatusCode == HttpStatusCode.NotFound, $"{path} answered {(int)response.StatusCode}"); Assert.DoesNotContain("a followers post", missing); Assert.DoesNotContain("a direct post", missing); } } [Fact] public async Task A_circle_has_no_page_a_community_has_one_and_the_instance_actor_has_none() { var token = TestContext.Current.CancellationToken; var member = await Author("member"); GroupEntity Group(GroupKind kind) => new() { UserName = $"{kind}{Guid.NewGuid():N}"[..20].ToLowerInvariant(), Name = $"The {kind}", Kind = kind, PostingPolicy = PostingPolicy.Followers, Members = new() { new GroupMember { AvatarId = member.Id, Role = GroupRole.Owner } } }; var circle = Group(GroupKind.Circle); var community = Group(GroupKind.Community); await DB.Default.SaveAsync(new[] { circle, community }, token); var inCommunity = await Publish(member, "said in the community", groupId: community.ID); var inCircle = await Seeded(member, "said in the circle", PostVisibility.Circle); await DB.Default.Update().MatchID(inCircle.ID).Modify(p => p.GroupId, circle.ID).ExecuteAsync(token); var (circlePage, circleBody) = await Page($"/@{circle.UserName}"); var (communityPage, communityBody) = await Page($"/@{community.UserName}"); Assert.Equal(HttpStatusCode.NotFound, circlePage.StatusCode); Assert.DoesNotContain("The Circle", circleBody); Assert.Equal(HttpStatusCode.NotFound, (await Page($"/@{circle.UserName}/{inCircle.ID}")).Response.StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await Page($"/@{circle.UserName}", "application/activity+json")).Response.StatusCode); Assert.Equal(HttpStatusCode.OK, communityPage.StatusCode); Hardened(communityPage); Assert.Contains("The Community", communityBody); Assert.Contains("said in the community", communityBody); Assert.Contains($"href=\"{member.PostHtmlUrl(inCommunity.ID)}\"", communityBody); Assert.Equal(HttpStatusCode.NotFound, (await Page("/@privapub")).Response.StatusCode); } [Fact] public async Task An_activitypub_request_for_a_page_is_sent_to_the_actor_or_the_object() { var author = await Author(); var open = await Publish(author, "fetch me as json"); var followersOnly = await Publish(author, "not for strangers", PostVisibility.FollowersOnly); var (profile, _) = await Page($"/@{author.UserName}", "application/activity+json"); var (post, _) = await Page($"/@{author.UserName}/{open.ID}", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""); var (hidden, _) = await Page($"/@{author.UserName}/{followersOnly.ID}", "application/activity+json"); Assert.Equal(HttpStatusCode.Redirect, profile.StatusCode); Assert.Equal(author.Uri, profile.Headers.Location!.ToString()); Assert.Equal(HttpStatusCode.Redirect, post.StatusCode); Assert.Equal(author.PostUri(open.ID), post.Headers.Location!.ToString()); Assert.Equal(HttpStatusCode.NotFound, hidden.StatusCode); Assert.False(profile.Headers.Contains("Content-Security-Policy")); } [Fact] public async Task Markup_in_a_post_a_title_or_a_bio_is_escaped_never_run() { var token = TestContext.Current.CancellationToken; var author = await Author(); await DB.Default.Update().MatchID(author.Id).Modify(a => a.Biography, " hi").ExecuteAsync(token); var rendered = await Publish(author, " **bold** "); var legacy = await Seeded(author, "", PostVisibility.Public); await DB.Default.Update().MatchID(legacy.ID).Modify(p => p.Title, "").ExecuteAsync(token); var (profile, profileBody) = await Page($"/@{author.UserName}"); var (post, postBody) = await Page($"/@{author.UserName}/{rendered.ID}"); var (old, oldBody) = await Page($"/@{author.UserName}/{legacy.ID}"); Assert.All(new[] { profile, post, old }, r => Assert.Equal(HttpStatusCode.OK, r.StatusCode)); foreach (var body in new[] { profileBody, postBody, oldBody }) { Assert.DoesNotContain("bold", postBody); Assert.Contains("<script>alert(legacy)</script>", oldBody); Assert.Contains("<script>alert(title)</script>", oldBody); } } }