- Received quotes: read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 Link tag; the quoted post is fetched once; a quoteAuthorization stamp is verified field by field on the quoted author's origin; a consent quote without a stamp is pending; an older-key quote of a public post is shown; Delete of a stamp revokes. Counts and a `quote` notification follow the accepted state. The `quote-inline` fallback survives sanitising and is removed from content when the real quote is shown. - Personas quote through `quoted_status_id`: posts that state a quote policy get a QuoteRequest and stay pending until an Accept brings a stamp we can verify, then an Update adds quoteAuthorization; posts that state none are quoted the older way, without `quote`; another persona's posts cannot be quoted yet (we issue no stamps). Quoting posts are delivered to the quoted author too. - Mastodon API: Status.quote (with the quoted status one level deep), quotes_count, quote_approval from the remote policy, GET /api/v1/statuses/:id/quotes, `quote` notifications, and api_versions.mastodon = 7. Checked live: GoToSocial's author-only quote policy is respected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
160 lines
7.2 KiB
C#
160 lines
7.2 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.ClientModels.Post;
|
|
using PrivaPub.Domain.Statuses;
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Tests.Support;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace PrivaPub.Tests.Federation
|
|
{
|
|
[Trait("Category", "Integration")]
|
|
public sealed class QuoteTests : IAsyncLifetime
|
|
{
|
|
Harness _harness;
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
_harness = await Harness.Start();
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_harness != default)
|
|
await _harness.DisposeAsync();
|
|
}
|
|
|
|
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
|
|
|
|
static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
|
|
|
|
async Task<Post> Delivered(RemoteActor author, string mentioned, Action<JsonObject> shape = default)
|
|
{
|
|
var note = new JsonObject
|
|
{
|
|
["id"] = NewId(author, "notes"), ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = "<p>hello</p>",
|
|
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(mentioned),
|
|
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = mentioned, ["name"] = "@someone" })
|
|
};
|
|
shape?.Invoke(note);
|
|
await _harness.Deliver(author, "/human-centipede", new JsonObject { ["id"] = NewId(author, "activities"), ["type"] = "Create", ["actor"] = author.Id, ["object"] = note });
|
|
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == note["id"]!.GetValue<string>()).ExecuteFirstAsync(TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
string Stamp(RemoteActor quotedAuthor, string quoting, string quoted)
|
|
{
|
|
var path = $"/stamps/{Guid.NewGuid():N}";
|
|
var id = Origin(quotedAuthor) + path;
|
|
_harness.Peer.Serve(path, new JsonObject
|
|
{
|
|
["@context"] = "https://www.w3.org/ns/activitystreams", ["id"] = id, ["type"] = "QuoteAuthorization",
|
|
["attributedTo"] = quotedAuthor.Id, ["interactingObject"] = quoting, ["interactionTarget"] = quoted
|
|
}.ToJsonString());
|
|
return id;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_misskey_quote_of_a_public_post_is_shown_and_counted()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var ann = new RemoteActor(_harness.Peer, "ann");
|
|
var kitty = new RemoteActor(_harness.Peer, "kitty");
|
|
var original = await Delivered(ann, alice.Uri);
|
|
|
|
var quoting = await Delivered(kitty, alice.Uri, n =>
|
|
{
|
|
n["_misskey_quote"] = original.ObjectURI;
|
|
n["content"] = $"<p>look<span class=\"quote-inline\"><br>RE: <a href=\"{original.ObjectURI}\">{original.ObjectURI}</a></span></p>";
|
|
});
|
|
|
|
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
|
|
Assert.Equal(original.ID, quoting.QuotedPostId);
|
|
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
|
|
var status = await new PrivaPub.Api.Mastodon.Mappers.MastodonMapper(_harness.Db, _harness.Local).Status(quoting, alice.Id, token);
|
|
Assert.Equal("accepted", status.Quote.State);
|
|
Assert.Equal(original.ID, status.Quote.QuotedStatus.Id);
|
|
Assert.DoesNotContain("RE:", status.Content);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_stamped_quote_is_accepted_a_forged_one_is_not_and_revoking_takes_it_back()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var ann = new RemoteActor(_harness.Peer, "ann");
|
|
var bob = new RemoteActor(_harness.Peer, "bob");
|
|
var original = await Delivered(ann, alice.Uri);
|
|
var quotingId = NewId(bob, "notes");
|
|
var stamp = Stamp(ann, quotingId, original.ObjectURI);
|
|
var forged = Stamp(ann, NewId(bob, "notes"), original.ObjectURI);
|
|
|
|
var quoting = await Delivered(bob, alice.Uri, n => { n["id"] = quotingId; n["quote"] = original.ObjectURI; n["quoteAuthorization"] = stamp; });
|
|
var cheat = await Delivered(bob, alice.Uri, n => { n["quote"] = original.ObjectURI; n["quoteAuthorization"] = forged; });
|
|
var waiting = await Delivered(bob, alice.Uri, n => n["quote"] = original.ObjectURI);
|
|
|
|
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
|
|
Assert.Equal(QuoteState.Unauthorized, cheat.QuoteState);
|
|
Assert.Equal(QuoteState.Pending, waiting.QuoteState);
|
|
|
|
await _harness.Deliver(ann, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = NewId(ann, "activities"), ["type"] = "Delete", ["actor"] = ann.Id, ["object"] = stamp
|
|
});
|
|
Assert.Equal(QuoteState.Revoked, (await DB.Default.Find<Post>().OneAsync(quoting.ID, token)).QuoteState);
|
|
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_persona_asks_before_quoting_and_is_stamped_when_accepted()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var ann = new RemoteActor(_harness.Peer, "ann");
|
|
var original = await Delivered(ann, alice.Uri, n => n["interactionPolicy"] = new JsonObject
|
|
{
|
|
["canQuote"] = new JsonObject { ["automaticApproval"] = new JsonArray(Addressing.Public) }
|
|
});
|
|
|
|
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "this", QuotedStatusId = original.ID }, token);
|
|
Assert.Equal(QuoteState.Pending, outcome.Post.QuoteState);
|
|
var request = Assert.Single(await _harness.Outgoing(ann.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "QuoteRequest");
|
|
Assert.Equal(original.ObjectURI, request["object"]!.GetValue<string>());
|
|
Assert.Equal(original.ObjectURI, request["instrument"]!["quote"]!.GetValue<string>());
|
|
|
|
await _harness.Deliver(ann, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = NewId(ann, "activities"), ["type"] = "Accept", ["actor"] = ann.Id,
|
|
["object"] = request["id"]!.GetValue<string>(), ["result"] = Stamp(ann, outcome.Post.ObjectURI, original.ObjectURI)
|
|
});
|
|
|
|
var quoting = await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token);
|
|
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
|
|
Assert.NotNull(quoting.QuoteAuthorizationURI);
|
|
Assert.Contains(await _harness.Outgoing(ann.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Update"
|
|
&& a["object"]!["quoteAuthorization"]?.GetValue<string>() == quoting.QuoteAuthorizationURI);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_legacy_post_is_quoted_without_asking_and_another_persona_cannot_be_quoted_yet()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (aliceRoot, alice) = await _harness.Persona("alice");
|
|
var (bobRoot, bob) = await _harness.Persona("bob");
|
|
var kitty = new RemoteActor(_harness.Peer, "kitty");
|
|
var legacy = await Delivered(kitty, alice.Uri);
|
|
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = "mine" }, token);
|
|
var bobs = await DB.Default.Find<Post>().Match(p => p.GroupUserId == bob.Id).ExecuteFirstAsync(token);
|
|
|
|
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "nice", QuotedStatusId = legacy.ID }, token);
|
|
Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState);
|
|
Assert.False(outcome.Post.QuoteByConsent);
|
|
Assert.False((await _harness.Statuses.Publish(alice, new StatusDraft { Text = "and this", QuotedStatusId = bobs.ID }, token)).Ok);
|
|
Assert.True((await _harness.Statuses.Publish(bob, new StatusDraft { Text = "me again", QuotedStatusId = bobs.ID }, token)).Ok);
|
|
}
|
|
}
|
|
}
|