using MongoDB.Entities; using PrivaPub.Models.Social; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; using System.Net; using System.Text.Json.Nodes; namespace PrivaPub.Tests.Http { // Mastodon's filters: a persona's v2 filters with keywords and statuses, the v1 API over their keywords, and the // `filtered` each status carries for the filters it matches [Trait("Category", "Integration")] public sealed class MastodonFiltersTests : IAsyncLifetime { PrivaPubHost _host; static CancellationToken Token => TestContext.Current.CancellationToken; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _host = await PrivaPubHost.Shared(); } public ValueTask DisposeAsync() => ValueTask.CompletedTask; static Task Create(Mastodon owner, string title, string action, params (string Keyword, bool WholeWord)[] keywords) => owner.Client.Json(HttpMethod.Post, "/api/v2/filters", new JsonObject { ["title"] = title, ["context"] = new JsonArray("home", "public", "thread"), ["filter_action"] = action, ["keywords_attributes"] = new JsonArray(keywords.Select(k => (JsonNode)new JsonObject { ["keyword"] = k.Keyword, ["whole_word"] = k.WholeWord }).ToArray()) }); [Fact] public async Task A_filter_is_made_changed_and_deleted_by_its_owner_only() { var alice = await _host.Mastodon("alice"); var mallory = await _host.Mastodon("mallory"); var made = (await Create(alice, "Spoilers", "hide", ("spoiler", true), ("leak", false))).Ok().Object; var id = made.Text("id"); Assert.Equal("hide", made.Text("filter_action")); Assert.Equal(new[] { "home", "public", "thread" }, made["context"]!.AsArray().Select(c => c!.GetValue())); var keywords = made["keywords"]!.AsArray(); Assert.Equal(new[] { "spoiler", "leak" }, keywords.Select(k => k.Text("keyword"))); Assert.Equal(new[] { true, false }, keywords.Select(k => k!.Flag("whole_word"))); Assert.Null(made["expires_at"]); var changed = (await alice.Client.Json(HttpMethod.Put, $"/api/v2/filters/{id}", new JsonObject { ["title"] = "Leaks", ["filter_action"] = "warn", ["expires_in"] = 3600, ["keywords_attributes"] = new JsonArray( new JsonObject { ["id"] = keywords[0].Text("id"), ["_destroy"] = true }, new JsonObject { ["keyword"] = "ending" }) })).Ok().Object; Assert.Equal("Leaks", changed.Text("title")); Assert.Equal("warn", changed.Text("filter_action")); Assert.NotNull(changed.Text("expires_at")); Assert.Equal(new[] { "leak", "ending" }, changed["keywords"]!.AsArray().Select(k => k.Text("keyword"))); var added = (await alice.Client.Post($"/api/v2/filters/{id}/keywords", ("keyword", "finale"), ("whole_word", "false"))).Ok().Object; Assert.False(added.Flag("whole_word")); var keywordId = added.Text("id"); Assert.Equal("finale", (await alice.Client.Get($"/api/v2/filters/keywords/{keywordId}")).Ok().Object.Text("keyword")); Assert.Equal("last scene", (await alice.Client.Put($"/api/v2/filters/keywords/{keywordId}", ("keyword", "last scene"))).Ok().Object.Text("keyword")); (await alice.Client.Delete($"/api/v2/filters/keywords/{keywordId}")).Ok(); Assert.Equal(2, (await alice.Client.Get($"/api/v2/filters/{id}/keywords")).Ok().Array.Count); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Create(alice, " ", "warn", ("x", true))).Status); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Create(alice, "x", "explode", ("x", true))).Status); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v2/filters", ("title", "x"), ("context[]", "everywhere"))).Status); Assert.Empty((await mallory.Client.Get("/api/v2/filters")).Ok().Array); Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v2/filters/{id}")).Status); Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Delete($"/api/v2/filters/keywords/{changed["keywords"]![0].Text("id")}")).Status); Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get("/api/v2/filters")).Status); (await alice.Client.Delete($"/api/v2/filters/{id}")).Ok(); Assert.Empty((await alice.Client.Get("/api/v2/filters")).Ok().Array); } [Fact] public async Task Statuses_carry_the_filters_they_match_by_word_and_by_id() { var alice = await _host.Mastodon("alice"); var bob = await _host.Mastodon("bob"); (await bob.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok(); var filterId = (await Create(alice, "Spoilers", "warn", ("spoiler", true), ("#tv", true))).Ok().Object.Text("id"); var other = (await Create(bob, "Not mine", "hide", ("spoiler", false))).Ok().Object.Text("id"); var whole = (await alice.Status("a big SPOILER ahead")).Text("id"); var partial = (await alice.Status("spoilers galore")).Text("id"); var tagged = (await alice.Status("watching #TV tonight")).Text("id"); var plain = (await alice.Status("nothing to see")).Text("id"); var warned = (await alice.Status("calm text", ("spoiler_text", "spoiler inside"))).Text("id"); var byId = (await alice.Client.Post($"/api/v2/filters/{filterId}/statuses", ("status_id", plain))).Ok().Object; Assert.Equal(plain, byId.Text("status_id")); var boost = (await bob.Client.Post($"/api/v1/statuses/{whole}/reblog")).Ok().Object.Text("id"); var home = (await alice.Client.Get("/api/v1/timelines/home?limit=40")).Ok().Array.ToDictionary(s => s.Text("id"), s => s!["filtered"]!.AsArray()); var match = Assert.Single(home[whole]); Assert.Equal(filterId, match!["filter"].Text("id")); Assert.Equal("warn", match["filter"].Text("filter_action")); Assert.Equal(new[] { "spoiler" }, match["keyword_matches"]!.AsArray().Select(k => k!.GetValue())); Assert.Empty(home[partial]); Assert.Equal(new[] { "#tv" }, Assert.Single(home[tagged])!["keyword_matches"]!.AsArray().Select(k => k!.GetValue())); Assert.Equal(new[] { byId.Text("id") }, Assert.Single(home[plain])!["status_matches"]!.AsArray().Select(k => k!.GetValue())); Assert.Single(home[warned]); Assert.Single((await alice.Client.Get($"/api/v1/statuses/{boost}")).Ok().Body["filtered"]!.AsArray()); var asBob = (await bob.Client.Get($"/api/v1/statuses/{partial}")).Ok().Body["filtered"]!.AsArray(); Assert.Equal(other, Assert.Single(asBob)!["filter"].Text("id")); Assert.Empty((await _host.Client().Get($"/api/v1/statuses/{whole}")).Ok().Body["filtered"]!.AsArray()); // an expired filter matches nothing await DB.Default.Update().MatchID(filterId).Modify(f => f.ExpiresAt, DateTime.UtcNow.AddMinutes(-1)).ExecuteAsync(Token); Assert.Empty((await alice.Client.Get($"/api/v1/statuses/{whole}")).Ok().Body["filtered"]!.AsArray()); } [Fact] public async Task Keywords_come_as_form_fields_listed_or_numbered() { var alice = await _host.Mastodon("alice"); var listed = (await alice.Client.Post("/api/v2/filters", ("title", "Listed"), ("context[]", "home"), ("keywords_attributes[][keyword]", "one"), ("keywords_attributes[][whole_word]", "false"), ("keywords_attributes[][keyword]", "two"), ("keywords_attributes[][whole_word]", "true"))).Ok().Object; Assert.Equal(new[] { ("one", false), ("two", true) }, listed["keywords"]!.AsArray().Select(k => (k.Text("keyword"), k!.Flag("whole_word")))); var numbered = (await alice.Client.Post("/api/v2/filters", ("title", "Numbered"), ("context[]", "public"), ("keywords_attributes[1][keyword]", "second"), ("keywords_attributes[0][keyword]", "first"), ("keywords_attributes[0][whole_word]", "false"))).Ok().Object; Assert.Equal(new[] { ("first", false), ("second", true) }, numbered["keywords"]!.AsArray().Select(k => (k.Text("keyword"), k!.Flag("whole_word")))); } [Fact] public async Task A_v1_filter_is_one_keyword_of_a_v2_filter() { var alice = await _host.Mastodon("alice"); var made = (await alice.Client.Post("/api/v1/filters", ("phrase", "election"), ("context[]", "home"), ("context[]", "notifications"), ("irreversible", "true"), ("whole_word", "false"))).Ok().Object; var id = made.Text("id"); Assert.Equal("election", made.Text("phrase")); Assert.True(made.Flag("irreversible")); Assert.False(made.Flag("whole_word")); var v2 = Assert.Single((await alice.Client.Get("/api/v2/filters")).Ok().Array)!; Assert.Equal("election", v2.Text("title")); Assert.Equal("hide", v2.Text("filter_action")); Assert.Equal(id, Assert.Single(v2["keywords"]!.AsArray()).Text("id")); var changed = (await alice.Client.Put($"/api/v1/filters/{id}", ("phrase", "elections"), ("irreversible", "false"))).Ok().Object; Assert.Equal("elections", changed.Text("phrase")); Assert.False(changed.Flag("irreversible")); Assert.Equal(new[] { id }, (await alice.Client.Get("/api/v1/filters")).Ok().Ids); Assert.Equal("elections", (await alice.Client.Get($"/api/v1/filters/{id}")).Ok().Object.Text("phrase")); (await alice.Client.Delete($"/api/v1/filters/{id}")).Ok(); Assert.Empty((await alice.Client.Get("/api/v2/filters")).Ok().Array); Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/filters/{id}")).Status); } } }