diff --git a/CLAUDE.md b/CLAUDE.md index 588553b..eda6765 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -562,10 +562,12 @@ tools/pasture/run.sh down # removes e - **Threads (`scenarios/threads.sh`, needs mastodon):** mastouser follows alice, carol answers alice's public post, and opening the thread on Mastodon finds carol's reply through alice's `replies` (the scenario makes Mastodon's copy look ten minutes old: Mastodon reads a thread's replies only five minutes after it first holds the post). 5 checks. -- **Followers synchronisation (`scenarios/followsync.sh`, needs mastodon):** PrivaPub forgets mastouser's follow of - alice (mongo), alice's followers-only post naming mastouser makes Mastodon read her roll-call and drop it; then +- **Followers synchronisation (`scenarios/followsync.sh`, needs mastodon):** sent: PrivaPub forgets mastouser's follow + of alice (mongo), alice's followers-only post naming mastouser makes Mastodon read her roll-call and drop it; then Mastodon forgets a follow (rails) and alice's next followers-only post makes it send the `Undo` PrivaPub applies. - Follows are checked in Mastodon's database: its API caches relationships for a day. 8 checks. + Received: alice and bob follow mastouser; Mastodon forgets alice's follow and mastouser's followers-only post makes + PrivaPub end it, then PrivaPub forgets it and the next makes PrivaPub undo it there. Follows are checked in Mastodon's + database: its API caches relationships for a day. 15 checks. - **Pins (`scenarios/pins.sh`, needs mastodon):** a Mastodon account pins and unpins while alice follows it, alice pins and unpins while it follows her, a fresh account's earlier pin shows once PrivaPub resolves it, and following it brings its earlier posts (its outbox). 9 checks. diff --git a/FEDERATION.md b/FEDERATION.md index 96dd8ee..a8ec410 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -72,7 +72,7 @@ covered by unit tests written in their documents' shape. `Like` with content) - [FEP-5feb: Search indexing consent](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md) (`indexable`) - [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) - (sent; see "Followers synchronisation") + (sent and honoured; see "Followers synchronisation") Planned (see `docs/ROADMAP.md`, phases P7 and P8, and the per-platform notes in `docs/INTEROP.md`): FEP-9098 (custom emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move), FEP-8967 (link @@ -179,6 +179,10 @@ persona's posts passed on to its followers. A deleted post answers 410 with a `T signed request with the persona's followers on the signer's server and nobody else's; unsigned, 401. A server whose view differs mends itself from it, Mastodon both ways (checked live). Mastodon's `Undo{Follow}` for a follow it never knew of has one id per account (`…#follows//undo`); when it comes again after a new follow, it ends that follow too. + Honoured the other way: when a delivery's header digests the sender's followers here otherwise than the personas + following it, PrivaPub reads the list it names (same origin, signed by the instance actor). A follow the list leaves out + ends, but only when the list is the one the digest describes; a request it lists is taken as accepted; a persona it + lists that follows nothing there sends `Undo{Follow}`, as Mastodon does. - **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`. A pin or an unpin is told to the post's audience as `Add` or `Remove` on `featured`, as Mastodon tells it. - **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it diff --git a/PrivaPub.Tests/Federation/FollowersSynchronizationTests.cs b/PrivaPub.Tests/Federation/FollowersSynchronizationTests.cs index 0a2de78..606fb14 100644 --- a/PrivaPub.Tests/Federation/FollowersSynchronizationTests.cs +++ b/PrivaPub.Tests/Federation/FollowersSynchronizationTests.cs @@ -10,6 +10,7 @@ using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Jobs; using PrivaPub.Models.Federation; using PrivaPub.Models.Jobs; +using PrivaPub.Models.Social; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; @@ -146,6 +147,87 @@ namespace PrivaPub.Tests.Federation Assert.Equal("duplicate", (await _harness.Deliver(bob, "/human-centipede", undo.DeepClone())).Reason); } + + // received: bob's delivery claims a digest of his followers here, and lists them at his own synchronisation URL + async Task Claimed(RemoteActor bob, string digest, params string[] listed) + { + var path = new Uri(bob.Id).AbsolutePath + "/followers/sync"; + _harness.Peer.Serve(path, new JsonObject + { + ["id"] = bob.Id + "/followers/sync", ["type"] = "OrderedCollection", + ["orderedItems"] = new JsonArray(listed.Select(l => (JsonNode)l).ToArray()) + }.ToJsonString()); + var note = $"{bob.Id}/notes/{Guid.NewGuid():N}"; + var request = bob.Post(Harness.Host, "/human-centipede", new JsonObject + { + ["id"] = note + "/activity", ["type"] = "Create", ["actor"] = bob.Id, ["to"] = new JsonArray(bob.Id + "/followers"), + ["object"] = new JsonObject { ["id"] = note, ["type"] = "Note", ["attributedTo"] = bob.Id, ["to"] = new JsonArray(bob.Id + "/followers"), ["content"] = "hi" } + }); + request.Headers[FollowersSynchronization.Header] = $"collectionId=\"{bob.Id}/followers\", url=\"{bob.Id}/followers/sync\", digest=\"{digest}\""; + await _harness.Receiver.Receive(request, default, Token); + var job = await DB.Default.Find().Match(j => j.Kind == JobKind.SynchronizeFollowing && j.State == JobState.Pending && j.Payload.Contains(bob.Id)) + .ExecuteSingleAsync(Token); + var handler = new FollowingSynchronization(_harness.Db, _harness.Local, _harness.Remote, _harness.Follows, _harness.Delivery); + Assert.Equal(JobResult.Done, (await handler.Handle(job, Token)).Result); + } + + async Task Follows(LocalActor persona, RemoteActor bob, FollowState state) + { + var following = new Following + { + AvatarId = persona.Id, TargetActorURI = bob.Id, TargetInboxURL = bob.Id + "/inbox", State = state, FollowActivityURI = persona.ActivityUri($"follow-{Guid.NewGuid():N}") + }; + await DB.Default.SaveAsync(following, Token); + return following; + } + + Task Following(LocalActor persona, RemoteActor bob) => + DB.Default.Find().Match(f => f.AvatarId == persona.Id && f.TargetActorURI == bob.Id).ExecuteFirstAsync(Token); + + [Fact] + public async Task A_claim_that_differs_is_mended_from_the_accounts_own_list() + { + var (_, alice) = await _harness.Persona("alice"); + var (_, carol) = await _harness.Persona("carol"); + var (_, dave) = await _harness.Persona("dave"); + var bob = new RemoteActor(_harness.Peer, "bob"); + await Follows(alice, bob, FollowState.Accepted); + await Follows(carol, bob, FollowState.Requested); + + await Claimed(bob, FollowersSynchronization.Digest([carol.Uri, dave.Uri]), carol.Uri, dave.Uri); + + Assert.Null(await Following(alice, bob)); + Assert.Equal(FollowState.Accepted, (await Following(carol, bob)).State); + var undos = (await _harness.Outgoing(bob.Id + "/inbox")).Where(a => a["type"]!.GetValue() == "Undo").ToList(); + Assert.Equal(new[] { alice.Uri, dave.Uri }.Order(), undos.Select(u => u["actor"]!.GetValue()).Order()); + var daves = Assert.Single(undos, u => u["actor"]!.GetValue() == dave.Uri); + Assert.Equal(("Follow", bob.Id), (daves["object"]!["type"]!.GetValue(), daves["object"]!["object"]!.GetValue())); + } + + [Fact] + public async Task A_list_the_claim_does_not_describe_ends_no_follow() + { + var (_, alice) = await _harness.Persona("alice"); + var bob = new RemoteActor(_harness.Peer, "bob"); + await Follows(alice, bob, FollowState.Accepted); + + await Claimed(bob, FollowersSynchronization.Digest(["https://privapub.test/peasants/someone"])); + + Assert.Equal(FollowState.Accepted, (await Following(alice, bob)).State); + } + + [Fact] + public async Task A_claim_that_agrees_reads_nothing() + { + var (_, alice) = await _harness.Persona("alice"); + var bob = new RemoteActor(_harness.Peer, "bob"); + await Follows(alice, bob, FollowState.Accepted); + + await Claimed(bob, FollowersSynchronization.Digest([alice.Uri])); + + Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path.EndsWith("/followers/sync")); + Assert.Equal(FollowState.Accepted, (await Following(alice, bob)).State); + } } [Trait("Category", "Integration")] diff --git a/PrivaPub/Federation/Actors/FollowersSynchronization.cs b/PrivaPub/Federation/Actors/FollowersSynchronization.cs index f10a580..178fb4a 100644 --- a/PrivaPub/Federation/Actors/FollowersSynchronization.cs +++ b/PrivaPub/Federation/Actors/FollowersSynchronization.cs @@ -55,6 +55,23 @@ namespace PrivaPub.Federation.Actors public static string HeaderValue(LocalActor actor, string digest) => $"collectionId=\"{actor.Followers}\", url=\"{RollCall(actor)}\", digest=\"{digest}\""; + // what a Collection-Synchronization header claims, written as a Signature header's parameters; default when it + // names no collection, list or digest + public static SynchronizationClaim Claim(string actorUri, string header) + { + var values = new Dictionary(StringComparer.Ordinal); + foreach (var part in (header ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + var equals = part.IndexOf('='); + if (equals > 0) + values[part[..equals].Trim()] = part[(equals + 1)..].Trim().Trim('"'); + } + return values.TryGetValue("collectionId", out var collection) && values.TryGetValue("url", out var url) && values.TryGetValue("digest", out var digest) + && digest.Length == 64 && digest.All(Uri.IsHexDigit) + ? new SynchronizationClaim(actorUri, collection, url, digest.ToLowerInvariant()) + : default; + } + // whether the activity, or the object it carries, is addressed to the actor's followers public static bool ForFollowers(JsonObject activity, LocalActor actor) => Addressed(activity, actor.Followers) || activity["object"] is JsonObject inner && Addressed(inner, actor.Followers); diff --git a/PrivaPub/Federation/Actors/FollowingSynchronization.cs b/PrivaPub/Federation/Actors/FollowingSynchronization.cs new file mode 100644 index 0000000..7f88f4a --- /dev/null +++ b/PrivaPub/Federation/Actors/FollowingSynchronization.cs @@ -0,0 +1,140 @@ +using MongoDB.Entities; + +using PrivaPub.Domain.Social; +using PrivaPub.Federation.Objects; +using PrivaPub.Federation.Outbox; +using PrivaPub.Federation.Rendering; +using PrivaPub.Infrastructure.Http; +using PrivaPub.Infrastructure.Jobs; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Jobs; +using PrivaPub.Models.Social; +using PrivaPub.StaticServices; + +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace PrivaPub.Federation.Actors +{ + public sealed record SynchronizationClaim(string Actor, string CollectionId, string Url, string Digest); + + // FEP-8fcf, received: an account elsewhere tells, with its delivery, a digest of its followers here. When the personas + // following it here digest otherwise, its partial list (read signed by the instance actor) says who they are: a follow + // only PrivaPub remembers ends, a request it answered without our knowing is accepted, and a follow only it remembers + // is undone by the persona, as Mastodon does. A follow ends only when the list is the one the digest describes. + public class FollowingSynchronization : IJobHandler + { + const int MaxPages = 10; + + readonly DbEntities _dbEntities; + readonly ILocalActorService _localActors; + readonly IRemoteActorService _remoteActors; + readonly IFollowService _follows; + readonly IDeliveryService _delivery; + + public FollowingSynchronization(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IFollowService follows, + IDeliveryService delivery) + { + _dbEntities = dbEntities; + _localActors = localActors; + _remoteActors = remoteActors; + _follows = follows; + _delivery = delivery; + } + + public JobKind Kind => JobKind.SynchronizeFollowing; + public int Concurrency => 1; + public int MaxAttempts => 3; + public int PerHostLimit => 1; + + // once for each delivery that claims it, as Mastodon compares each: cheap while the digests agree + public static string DedupeKey(SynchronizationClaim claim, string activityId) => + activityId == default ? default : $"followsync|{activityId}|{claim.Digest}"; + + public async Task Handle(Job job, CancellationToken token) + { + var claim = JsonSerializer.Deserialize(job.Payload); + var actor = claim == default ? default : await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == claim.Actor && !a.DeletionAt.HasValue).ExecuteFirstAsync(token); + if (actor == default || string.IsNullOrEmpty(actor.FollowersURL) || claim.CollectionId != actor.FollowersURL || !Origin.Same(claim.Url, actor.ActorURI)) + return JobOutcome.Done; + + var followings = await _dbEntities.Followings.Match(f => f.TargetActorURI == actor.ActorURI && !f.TargetIsLocal).ExecuteAsync(token); + var personas = new Dictionary(StringComparer.Ordinal); + foreach (var following in followings) + if (await _localActors.FindById(LocalActorKind.Person, following.AvatarId, token) is { } persona) + personas[persona.Uri] = (persona, following); + var accepted = personas.Values.Where(p => p.Following.State == FollowState.Accepted).Select(p => p.Persona.Uri).ToList(); + if (FollowersSynchronization.Digest(accepted) == claim.Digest) + return JobOutcome.Done; + + var listed = await Listed(claim.Url, token); + if (listed == default) + return JobOutcome.Retry("the partial followers collection could not be read"); + + var complete = FollowersSynchronization.Digest(listed) == claim.Digest; + var named = listed.ToHashSet(StringComparer.Ordinal); + if (complete) + foreach (var uri in accepted.Where(uri => !named.Contains(uri))) + await _follows.UnfollowAs(personas[uri].Persona, actor.ActorURI, token); + + foreach (var uri in named) + { + if (personas.TryGetValue(uri, out var known)) + { + if (known.Following.State == FollowState.Requested) + await DB.Default.Update().MatchID(known.Following.ID).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); + continue; + } + if (await _localActors.FindByUri(uri, token) is not { Kind: LocalActorKind.Person } stranger) + continue; + // (it has no id for a follow PrivaPub never made; it is undone by actor and object) + var undo = new JsonObject + { + ["@context"] = ActivityPubRenderer.ActivityStreams, + ["id"] = stranger.ActivityUri($"undo-follow-sync-{Guid.NewGuid():N}"), + ["type"] = "Undo", + ["actor"] = stranger.Uri, + ["object"] = new JsonObject { ["type"] = "Follow", ["actor"] = stranger.Uri, ["object"] = actor.ActorURI } + }; + await _delivery.Enqueue(stranger, new[] { string.IsNullOrEmpty(actor.InboxURL) ? actor.SharedInboxURL : actor.InboxURL }, undo, token); + } + return JobOutcome.Done; + } + + // the ids the collection lists, its pages followed on its own server; default when it cannot be read + async Task> Listed(string url, CancellationToken token) + { + using var scope = HttpScope.For("followers-sync"); + var items = new List(); + var next = url; + for (var page = 0; page < MaxPages && next != default; page++) + { + using var fetched = await _remoteActors.FetchObject(next, token); + if (fetched == default || fetched.Root.ValueKind != JsonValueKind.Object) + return page == 0 ? default : items; + var root = fetched.Root; + if (page == 0 && !Has(root, "orderedItems") && !Has(root, "items") && Link(root, "first") is { } first) + { + next = Origin.Same(first, url) ? first : default; + continue; + } + foreach (var name in new[] { "orderedItems", "items" }) + if (root.TryGetProperty(name, out var list) && list.ValueKind == JsonValueKind.Array) + items.AddRange(list.EnumerateArray().Select(Id).Where(id => id != default)); + next = Link(root, "next") is { } after && Origin.Same(after, url) ? after : default; + } + return items; + } + + static bool Has(JsonElement node, string name) => node.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Array; + + static string Link(JsonElement node, string name) => node.TryGetProperty(name, out var value) ? Id(value) : default; + + static string Id(JsonElement node) => node.ValueKind switch + { + JsonValueKind.String => node.GetString(), + JsonValueKind.Object when node.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String => id.GetString(), + _ => default + }; + } +} diff --git a/PrivaPub/Federation/Inbox/InboxReceiver.cs b/PrivaPub/Federation/Inbox/InboxReceiver.cs index 460bfef..43318c3 100644 --- a/PrivaPub/Federation/Inbox/InboxReceiver.cs +++ b/PrivaPub/Federation/Inbox/InboxReceiver.cs @@ -204,6 +204,9 @@ namespace PrivaPub.Federation.Inbox // once per follow, so a retry still counts as a copy if (!queued && dedupe != default && type == "Undo" && await FollowAgain(activity, actorUri, token) is { } follow) queued = await _queue.Enqueue(JobKind.ProcessInbox, queuedPayload, host, $"{dedupe}|{Digest(activity)}|{follow}", token); + // FEP-8fcf: what the sender says of its followers here (FollowingSynchronization) + if (queued && forwardedBy == default && FollowersSynchronization.Claim(actorUri, request.Headers[FollowersSynchronization.Header].ToString()) is { } claim) + await _queue.Enqueue(JobKind.SynchronizeFollowing, JsonSerializer.Serialize(claim), host, FollowingSynchronization.DedupeKey(claim, activityId), token); _logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri); return new(StatusCodes.Status202Accepted, Reason: !queued ? "duplicate" : forwardedBy != default ? "forwarded" : "queued"); } diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index 3d2c4b5..deb8881 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -112,6 +112,7 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/PrivaPub/Models/Jobs/Job.cs b/PrivaPub/Models/Jobs/Job.cs index 28ac15a..c22cef5 100644 --- a/PrivaPub/Models/Jobs/Job.cs +++ b/PrivaPub/Models/Jobs/Job.cs @@ -34,7 +34,8 @@ namespace PrivaPub.Models.Jobs CountAccount, PublishScheduled, FetchReplies, - BackfillOutbox + BackfillOutbox, + SynchronizeFollowing } public enum JobState diff --git a/docs/INTEROP.md b/docs/INTEROP.md index c789087..10f6c64 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -1165,7 +1165,7 @@ snapshots; `/api/privapub/v1/cdns` and `/cdns/:domain` group servers by CDN, wee | Query string | GoToSocial, Akkoma 3.20 and Misskey 2026.10 sign it; GoToSocial retries without it | Verify both ways | P1 | | `hs2019` | The algorithm comes from the key; some senders hash with SHA-512 | Try rsa-sha256, then sha512 | P2 | | Move (FEP-7628, FINAL 2026-08-26) | See Mastodon | Inbound P1; outbound per persona P3 (never link personas) | P1 / P3 | -| Followers sync (FEP-8fcf) | Mastodon, Pixelfed, Fedify and WordPress | Send and honour `Collection-Synchronization`. It protects followers-only posts. | P2 | +| Followers sync (FEP-8fcf) | Mastodon, Pixelfed, Fedify and WordPress | Send and honour `Collection-Synchronization`. It protects followers-only posts. **Done 2026-10-06**, both ways, checked live against Mastodon. | P2 | | Instance actor discovery | FEP-d556 (FINAL), FEP-2677 | Publish both | P3 | | Relays (FEP-ae0c, FINAL) | Mastodon-style relays forward LD-signed Creates; LitePub-style relays Announce. GoToSocial 0.22 subscribes to relays. | Client for both styles; refetch or check an integrity proof. This is how a small server sees beyond its follows. | P2 | | FASP | Mastodon 4.4+, behind a flag. Its data sharing pushes content to a third party. | Do not join the data sharing; maybe consume search and trends | P3 | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7a9ba50..ef019c5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -672,8 +672,9 @@ it, raw where it doesn't. - re-run WebFinger on a rename; - inbound `Block`; `Add`/`Remove` of pins: **done 2026-10-05** (both ways, a community's pins too, the `featured` collection read with an account's counts; checked live against Mastodon); - - FEP-8fcf followers sync: sending **done 2026-10-06** (owner decision; the digest of a persona's followers on the - receiving server, and a signed roll-call listing only them; Mastodon mends both ways from it, checked live); + - FEP-8fcf followers sync: **done 2026-10-06** (owner decision; sent: the digest of a persona's followers on the + receiving server, and a signed roll-call listing only them; honoured: a sender's digest of its followers here, + mended from its list; checked live against Mastodon, all four ways a follow can be lost); - `indexable`/`discoverable`/`searchableBy`; - edit history from `formerRepresentations`; - PeerTube reply rules and `ApproveReply`. diff --git a/tools/pasture/scenarios/followsync.sh b/tools/pasture/scenarios/followsync.sh index e43091e..b96a687 100644 --- a/tools/pasture/scenarios/followsync.sh +++ b/tools/pasture/scenarios/followsync.sh @@ -1,7 +1,9 @@ -# Followers synchronisation (FEP-8fcf, owner decision 2026-10-06): alice's followers-only post tells Mastodon, in a signed -# header, a digest of her followers there; when Mastodon's view differs it reads her roll-call and mends itself. Both ways: -# a follow PrivaPub lost (Mastodon drops it), and a follow Mastodon lost (it sends the Undo PrivaPub then applies). The -# roll-call lists Mastodon's accounts only, and only to a signed request. Needs the mastodon peer. +# Followers synchronisation (FEP-8fcf, owner decision 2026-10-06), both ways. Sent: alice's followers-only post tells +# Mastodon, in a signed header, a digest of her followers there; when Mastodon's view differs it reads her roll-call and +# mends itself: a follow PrivaPub lost (Mastodon drops it), and a follow Mastodon lost (it sends the Undo PrivaPub +# applies). The roll-call answers only a signed request. Received: mastouser's followers-only post tells PrivaPub a digest +# of its followers here (alice and bob); PrivaPub reads Mastodon's list and ends a follow Mastodon lost, and undoes one +# only Mastodon remembers. Needs the mastodon peer. M=https://mastodon.test:6443 mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; } . "$here/peers/mastodon.sh" @@ -49,3 +51,29 @@ m_rails "a = Account.find_local(\"mastouser\"); t = Account.find_by(uri: \"$alic [ "$(m_follows)" = "False" ] && ok "Mastodon forgets the follow" || ko "Mastodon still has the follow" quiet_post "for my followers again $run" until_true 60 '[ "$(alice_followers)" = "0" ]' && ok "Mastodon reads the roll-call and undoes the follow PrivaPub had" || ko "PrivaPub still counts mastouser" + +echo " received: mastouser's followers here" +BT=$(privapub_token bob_sync) +BH="Authorization: Bearer $BT" +m_on_p=$(curl -s -H "$AH" "$P/api/v2/search?q=mastouser@mastodon.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +p_follows() { curl -s -H "$1" "$P/api/v1/accounts/relationships?id[]=$m_on_p" | j "print(d[0]['following'])"; } +m_followed_by() { m_rails "puts Follow.exists?(account: Account.find_by(uri: \"${P_BASE:-https://privapub.test}/peasants/$1\"), target_account: Account.find_local(\"mastouser\")) ? \"True\" : \"False\""; } +m_quiet() { mcurl -s -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses" -d "status=$1&visibility=private"; } +for who in "$AH" "$BH"; do + [ "$(p_follows "$who")" = "True" ] || curl -s -o /dev/null -X POST -H "$who" "$P/api/v1/accounts/$m_on_p/follow" +done +until_true 45 '[ "$(p_follows "$AH")" = "True" ] && [ "$(p_follows "$BH")" = "True" ] && [ "$(m_followed_by alice_sync)" = "True" ] && [ "$(m_followed_by bob_sync)" = "True" ]' \ + && ok "alice and bob follow mastouser" || ko "alice and bob never both followed mastouser" + +m_rails "Follow.where(account: Account.find_by(uri: \"https://privapub.test/peasants/alice_sync\"), target_account: Account.find_local(\"mastouser\")).destroy_all" >/dev/null +[ "$(m_followed_by alice_sync)" = "False" ] && ok "Mastodon forgets alice's follow" || ko "Mastodon still has alice's follow" +m_quiet "for my followers $run" +until_true 60 '[ "$(p_follows "$AH")" = "False" ]' && ok "PrivaPub reads mastouser's list and ends alice's follow" || ko "alice still follows mastouser on PrivaPub" +[ "$(p_follows "$BH")" = "True" ] && ok "bob's follow stays" || ko "bob's follow ended too" + +curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/follow" +until_true 45 '[ "$(p_follows "$AH")" = "True" ] && [ "$(m_followed_by alice_sync)" = "True" ]' && ok "alice follows mastouser again" || ko "alice could not follow mastouser again" +p_mongo "db.Following.deleteMany({AvatarId:'$alice_id', TargetActorURI:'$mastouser_uri'})" >/dev/null +[ "$(p_follows "$AH")" = "False" ] && ok "PrivaPub forgets alice's follow" || ko "PrivaPub still has alice's follow" +m_quiet "for my followers again $run" +until_true 60 '[ "$(m_followed_by alice_sync)" = "False" ]' && ok "PrivaPub undoes the follow only Mastodon remembered" || ko "Mastodon still has alice following mastouser"