Files
SocialPub/PrivaPub.Tests/Web/PublicPagesTests.cs
T

222 lines
10 KiB
C#
Raw Normal View History

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<LocalActor> Author(string name = "author")
{
var persona = await _host.Persona(await _host.SignUp(), name);
return await _host.Get<ILocalActorService>().FindById(LocalActorKind.Person, persona.Id, TestContext.Current.CancellationToken);
}
async Task<Post> Publish(LocalActor author, string text, PostVisibility visibility = PostVisibility.Public, string groupId = default)
{
using var scope = _host.Services.CreateScope();
var outcome = await scope.ServiceProvider.GetRequiredService<IStatusService>()
.Publish(author, new StatusDraft { Text = text, Visibility = visibility, GroupId = groupId }, TestContext.Current.CancellationToken);
Assert.True(outcome.Ok, outcome.Error);
return outcome.Post;
}
async Task<Post> 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<IStatusService>().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("<meta name=\"robots\" content=\"noindex, noarchive, nofollow\">", body);
Assert.Contains($"<link rel=\"alternate\" type=\"application/activity+json\" href=\"{author.Uri}\">", 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<IStatusService>().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<Post>().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<Avatar>().MatchID(author.Id).Modify(a => a.Biography, "<script>alert(bio)</script> hi").ExecuteAsync(token);
var rendered = await Publish(author, "<script>alert(post)</script> **bold** <img src=x onerror=alert(1)>");
var legacy = await Seeded(author, "<script>alert(legacy)</script>", PostVisibility.Public);
await DB.Default.Update<Post>().MatchID(legacy.ID).Modify(p => p.Title, "<script>alert(title)</script>").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("<script", body, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("<img", body, StringComparison.OrdinalIgnoreCase);
}
Assert.Contains("&lt;script&gt;alert(bio)&lt;/script&gt;", profileBody);
Assert.Contains("&lt;script&gt;alert(post)&lt;/script&gt;", postBody);
Assert.Contains("<strong>bold</strong>", postBody);
Assert.Contains("&lt;script&gt;alert(legacy)&lt;/script&gt;", oldBody);
Assert.Contains("&lt;script&gt;alert(title)&lt;/script&gt;", oldBody);
}
}
}