- Our public and unlisted posts state interactionPolicy.canQuote. The policy comes from the post, then the persona
(`source[quote_policy]`: public, followers or nobody; default public as the owner chose), and is always nobody for
followers-only posts and DMs.
- QuoteRequests are answered with Accept{result} naming a parrot-licence at /peasants/{name}/parrot-licences/{id}
(the route name the owner chose), or with Reject. Followers-only checks that the requester really follows.
- A licence is a QuoteAuthorization naming both posts; revoking it (POST /api/v1/statuses/:id/quotes/:quoting_id/revoke)
marks it 410, sends Delete{licence} to the quoter and the persona's followers, and revokes our own copy of the quote.
- A quote that arrives with one of our licences is accepted only if that licence is ours, unrevoked and names exactly
that quoting post. One persona quoting another gets a licence too.
- Mastodon API: quote_approval for our posts (automatic, followers, current_user), `quote_approval_policy` when posting,
PUT /api/v1/statuses/:id/interaction_policy, `source.quote_policy`.
Checked live: GoToSocial still accepts our posts with the policy stated, and leaves likes, replies and boosts open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
222 lines
11 KiB
C#
222 lines
11 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_a_persona_that_allows_nobody_cannot_be_quoted()
|
|
{
|
|
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);
|
|
bob.Settings.QuotePolicy = PrivaPub.Models.User.QuotePolicies.Nobody;
|
|
var bobs = (await _harness.Statuses.Publish(bob, new StatusDraft { Text = "mine" }, token)).Post;
|
|
|
|
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);
|
|
}
|
|
|
|
JsonObject Request(RemoteActor quoter, string quotedUri, string quotingId) => new()
|
|
{
|
|
["id"] = NewId(quoter, "activities"), ["type"] = "QuoteRequest", ["actor"] = quoter.Id, ["object"] = quotedUri,
|
|
["instrument"] = new JsonObject { ["id"] = quotingId, ["type"] = "Note", ["attributedTo"] = quoter.Id, ["quote"] = quotedUri, ["content"] = "<p>so true</p>" }
|
|
};
|
|
|
|
[Fact]
|
|
public async Task Anyone_may_quote_a_persona_by_default_and_the_licence_can_be_revoked()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
|
await _harness.Deliver(mallory, "/human-centipede", new JsonObject { ["id"] = NewId(mallory, "follows"), ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri });
|
|
var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "quote me" }, token)).Post;
|
|
var created = (await _harness.Outgoing(mallory.Id + "/inbox")).Single(a => a["type"]!.GetValue<string>() == "Create")["object"]!;
|
|
Assert.Equal(Addressing.Public, created["interactionPolicy"]!["canQuote"]!["automaticApproval"]![0]!.GetValue<string>());
|
|
|
|
var quotingId = NewId(mallory, "notes");
|
|
await _harness.Deliver(mallory, "/human-centipede", Request(mallory, original.ObjectURI, quotingId));
|
|
var accept = Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Accept" && a["result"] != null);
|
|
var licence = accept["result"]!.GetValue<string>();
|
|
Assert.StartsWith(alice.Uri + "/parrot-licences/", licence);
|
|
|
|
var quoting = await Delivered(mallory, alice.Uri, n => { n["id"] = quotingId; n["quote"] = original.ObjectURI; n["quoteAuthorization"] = licence; });
|
|
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
|
|
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
|
|
|
|
Assert.True(await _harness.Quotes.RevokeLicence(original, quoting, token));
|
|
Assert.Equal(QuoteState.Revoked, (await DB.Default.Find<Post>().OneAsync(quoting.ID, token)).QuoteState);
|
|
Assert.Contains(await _harness.Outgoing(mallory.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Delete" && a["object"]!.GetValue<string>() == licence);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_followers_only_persona_turns_a_stranger_down()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
alice.Settings.QuotePolicy = PrivaPub.Models.User.QuotePolicies.Followers;
|
|
var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "friends only quoting" }, token)).Post;
|
|
var stranger = new RemoteActor(_harness.Peer, "stranger");
|
|
|
|
await _harness.Deliver(stranger, "/human-centipede", Request(stranger, original.ObjectURI, NewId(stranger, "notes")));
|
|
|
|
Assert.Equal("Reject", Assert.Single(await _harness.Outgoing(stranger.Id + "/inbox"))["type"]!.GetValue<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task One_persona_quotes_another_with_a_licence()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var (_, bob) = await _harness.Persona("bob");
|
|
var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "public thought" }, token)).Post;
|
|
|
|
var outcome = await _harness.Statuses.Publish(bob, new StatusDraft { Text = "agreed", QuotedStatusId = original.ID }, token);
|
|
|
|
Assert.True(outcome.Ok);
|
|
Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState);
|
|
Assert.StartsWith(alice.Uri + "/parrot-licences/", outcome.Post.QuoteAuthorizationURI);
|
|
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
|
|
}
|
|
}
|
|
}
|