A sender's digest of its followers here is compared and mended

FEP-8fcf, received. When a delivery's Collection-Synchronization header digests the sender's followers on PrivaPub
otherwise than the personas following it, a job reads the list the header names (on the sender's origin, signed by the
instance actor): a follow the list leaves out ends, 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. Each
claiming delivery is compared once.

Checked live (scenarios/followsync.sh, now 15 checks): PrivaPub ends a follow Mastodon lost and undoes one only
Mastodon remembered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 07:46:46 +02:00
1 parent bd18de35a6
commit e0682fa868
11 files changed
+291 -12

No files matched your search

+5 -3
View File
@@ -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.
+5 -1
View File
@@ -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
@@ -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<Job>().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<Following> 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> Following(LocalActor persona, RemoteActor bob) =>
DB.Default.Find<Following>().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<string>() == "Undo").ToList();
Assert.Equal(new[] { alice.Uri, dave.Uri }.Order(), undos.Select(u => u["actor"]!.GetValue<string>()).Order());
var daves = Assert.Single(undos, u => u["actor"]!.GetValue<string>() == dave.Uri);
Assert.Equal(("Follow", bob.Id), (daves["object"]!["type"]!.GetValue<string>(), daves["object"]!["object"]!.GetValue<string>()));
}
[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")]
@@ -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<string, string>(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);
@@ -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<JobOutcome> Handle(Job job, CancellationToken token)
{
var claim = JsonSerializer.Deserialize<SynchronizationClaim>(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<string, (LocalActor Persona, Following Following)>(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<Following>().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<List<string>> Listed(string url, CancellationToken token)
{
using var scope = HttpScope.For("followers-sync");
var items = new List<string>();
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
};
}
}
@@ -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");
}
@@ -112,6 +112,7 @@ namespace PrivaPub.Middleware
.AddSingleton<IJobHandler, RecoveryJob>()
.AddSingleton<IJobHandler, Federation.Actors.AccountCountsJob>()
.AddSingleton<IJobHandler, Federation.Actors.OutboxBackfill>()
.AddSingleton<IJobHandler, Federation.Actors.FollowingSynchronization>()
.AddSingleton<IJobHandler, PollCloseJob>()
.AddSingleton<IJobHandler, Domain.Statuses.PublishScheduledJob>()
.AddSingleton<InstanceDescriber>()
+2 -1
View File
@@ -34,7 +34,8 @@ namespace PrivaPub.Models.Jobs
CountAccount,
PublishScheduled,
FetchReplies,
BackfillOutbox
BackfillOutbox,
SynchronizeFollowing
}
public enum JobState
+1 -1
View File
@@ -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 |
+3 -2
View File
@@ -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`.
+32 -4
View File
@@ -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"