From 47d6e22988fbd6a93686abffe58025d405d0d674 Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 15:44:13 +0200 Subject: [PATCH] PieFed joins the pasture; the instance actor answers at the root PieFed 1.7.17 (dockurr's image of the release) runs in the pasture with its Celery worker on the shared Postgres and Redis, and scenarios/piefed.sh checks it both ways: 29 checks, communities, titled threads, comments, votes up and down, a community poll and a vote in it, private messages, a moderator's lock, unlock and removal, the unfollow and statistics. What it showed: - PieFed sends a community's announces to the inbox of the Application at a peer's root (as Lemmy serves its site actor) and to /inbox otherwise. PrivaPub answered 404 at its root, so every announce went to an /inbox it does not have. The instance actor now answers at / for ActivityPub requests, unsigned under SecureMode as at its own address. - PieFed keeps serving a thread its moderator removed, so the removal could never be checked against the post's origin. A community on the post's own server now speaks for it; one elsewhere still waits for the origin to say the post is gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 9 ++ FEDERATION.md | 14 +- .../Federation/CommunityEditsTests.cs | 42 +++++- PrivaPub.Tests/Http/FederationGetTests.cs | 15 +++ .../Controllers/PeasantsController.cs | 13 +- .../Inbox/Handlers/AnnounceHandler.cs | 15 ++- docs/INTEROP.md | 14 +- docs/ROADMAP.md | 3 + tools/pasture/Caddyfile | 5 + tools/pasture/peers/piefed.sh | 65 +++++++++ tools/pasture/run.sh | 2 +- tools/pasture/scenarios/piefed.sh | 124 ++++++++++++++++++ 12 files changed, 304 insertions(+), 17 deletions(-) create mode 100644 tools/pasture/peers/piefed.sh create mode 100644 tools/pasture/scenarios/piefed.sh diff --git a/CLAUDE.md b/CLAUDE.md index 4909d4b..83efd6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -520,6 +520,15 @@ tools/pasture/run.sh down # removes e server started, so the scenario waits for that worker (`lm_worker`) before its first follow, and it sends what it queued every 30 seconds, so a vote or a moderator's act takes up to a minute. Its lock, unlock and unban need a `reason`, or it refuses them and federates nothing. 29 checks, among them a moderator's lock, ban and removal. +- **PieFed (1.7.17):** dockurr's image of the release, its web app (with PieFed's own cron, `CRON=true`, for its send + queue) and a Celery worker on the shared Postgres and Redis (dbs 10 and 11), sharing the `pasture-piefed-media` + volume. httpx trusts only certifi's bundle, so the pasture's is mounted over it. `flask init-db` reads its admin + (pfuser) from stdin and drops every table it finds, so it runs once, after the web app's migrations ("Starting + Gunicorn"). Its API is Lemmy's v3 under `/api/alpha` with a JWT (`pf` in the scenario); `resolve_object` answers a + view (`community.community.id`). It sends a community's announces to the Application at a peer's root, assuming + `/inbox` when there is none, and keeps serving a thread its moderator removed. `scenarios/piefed.sh`, 29 checks: + communities both ways, threads with titles, comments, votes up and down both ways, a community poll and alice's vote, + private messages both ways, a moderator's lock, unlock and removal, the unfollow, statistics. - **Hollo (0.9.19):** Fedify's microblog server on the shared Postgres, set up through its web form (which checks `Origin` against `Host`, so the request names `hollo.test` without the port). It needs a 44-character `SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario. diff --git a/FEDERATION.md b/FEDERATION.md index 7b38bc8..64a24f6 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -29,6 +29,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d - **Mobilizon 5.2.4** - **Gancio 1.28.2** - **Funkwhale 2.0.11** +- **PieFed 1.7.17** - in the town only (a seeded community checked server by server): **Hollo 0.9.19**, **Iceshrimp.NET 2026.1.2-beta**, **Pleroma 2.10.2** @@ -44,8 +45,8 @@ Checked both ways, where the peer has the feature: - communities and circles; - blocks and unfollows. -`docs/INTEROP.md` has each peer's evidence and what is still expected to fail. PeerTube, PieFed, Mbin and the others are -covered by unit tests written in their documents' shape. +`docs/INTEROP.md` has each peer's evidence and what is still expected to fail. Mbin and the others not yet in the pasture +are covered by unit tests written in their documents' shape. ## Supported FEPs @@ -89,7 +90,9 @@ The names are the project's own and are stable; resolve actors through WebFinger personas made on the same day do not share a date. `indexable` is `false` unless the persona turns it on, and `discoverable` is `true` unless it turns that off (the deploy's own persona, @thepra, is undiscoverable). - The instance actor is `/peasants/privapub` (type `Application`). It signs every fetch PrivaPub makes, so no avatar's key - is used to read another server's content. + is used to read another server's content. It also answers at the server's root (`/`) for a request that asks for + ActivityPub, as Lemmy's site actor does: PieFed sends a community's announces to the inbox of the Application at a + peer's root, and to `/inbox` when there is none. ## Groups @@ -111,8 +114,9 @@ A group is either a **community** or a **circle**. from its own origin, never taken from the announce. They are taken from a group someone here follows, and also when they relay a vote on one of our posts in a thread of that group (Lemmy sends a vote to the community alone). A post keeps the group its `audience` names, however it arrived. -- A remote community's **moderation** reaches the posts it holds. A removal (`Announce{Delete}`) is believed once the - post's origin answers it gone. A lock (`Announce{Lock}`, or `commentsEnabled: false` on the post) refuses replies, +- A remote community's **moderation** reaches the posts it holds. A removal (`Announce{Delete}`) is believed at once + from a community on the post's own server, which speaks for it (PieFed keeps serving a thread its moderator removed), + and from a community elsewhere once the post's origin answers it gone. A lock (`Announce{Lock}`, or `commentsEnabled: false` on the post) refuses replies, ours included, until `Undo{Lock}`. A ban of a persona (`Announce{Block}` with the community as `target`) shows as `blocked_by` on the community and refuses the persona's posts and replies there until the `Undo`. diff --git a/PrivaPub.Tests/Federation/CommunityEditsTests.cs b/PrivaPub.Tests/Federation/CommunityEditsTests.cs index 692bc59..4b56b05 100644 --- a/PrivaPub.Tests/Federation/CommunityEditsTests.cs +++ b/PrivaPub.Tests/Federation/CommunityEditsTests.cs @@ -30,11 +30,12 @@ namespace PrivaPub.Tests.Federation static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); - async Task<(RemoteActor Community, RemoteActor Poster, JsonObject Note, Post Post)> Announced() + // a post in a community the persona follows; the community on the poster's server unless another origin is given + async Task<(RemoteActor Community, RemoteActor Poster, JsonObject Note, Post Post)> Announced(string communityOrigin = default) { var token = TestContext.Current.CancellationToken; var (root, alice) = await _harness.Persona("alice"); - var community = new RemoteActor(_harness.Peer, "cats", type: "Group"); + var community = new RemoteActor(_harness.Peer, "cats", communityOrigin, type: "Group"); var poster = new RemoteActor(_harness.Peer, "poster"); await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token); await DB.Default.Update().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); @@ -109,6 +110,43 @@ namespace PrivaPub.Tests.Federation Assert.True(await DB.Default.Find().Match(d => d.ObjectURI == post.ObjectURI).ExecuteAnyAsync(token)); } + // PieFed keeps serving a thread its moderator removed: a community on the post's own server speaks for it + [Fact] + public async Task A_removal_a_community_on_the_posts_server_announces_is_taken_though_the_post_is_still_served() + { + var token = TestContext.Current.CancellationToken; + var (community, poster, note, post) = await Announced(); + + await Announce(community, new JsonObject + { + ["id"] = $"{Origin(poster)}/delete/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = poster.Id, + ["object"] = note["id"]!.GetValue(), ["summary"] = "off topic" + }); + + Assert.False(await DB.Default.Find().Match(p => p.ID == post.ID).ExecuteAnyAsync(token)); + } + + [Fact] + public async Task A_removal_a_community_elsewhere_announces_waits_for_the_posts_server_to_say_it_is_gone() + { + var token = TestContext.Current.CancellationToken; + var (community, poster, note, post) = await Announced(_harness.Peer.B); + var delete = new JsonObject + { + ["id"] = $"{Origin(community)}/delete/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = community.Id, + ["object"] = note["id"]!.GetValue() + }; + + await Announce(community, (JsonObject)delete.DeepClone()); + var kept = await DB.Default.Find().Match(p => p.ID == post.ID).ExecuteAnyAsync(token); + _harness.Peer.Serve(new Uri(note["id"]!.GetValue()).AbsolutePath, new JsonObject { ["id"] = note["id"]!.GetValue(), ["type"] = "Tombstone" }.ToJsonString()); + delete["id"] = $"{Origin(community)}/delete/{Guid.NewGuid():N}"; + await Announce(community, delete); + + Assert.True(kept); + Assert.False(await DB.Default.Find().Match(p => p.ID == post.ID).ExecuteAnyAsync(token)); + } + [Fact] public async Task A_fetched_object_does_not_wear_the_signature_of_the_activity_that_caused_the_fetch() { diff --git a/PrivaPub.Tests/Http/FederationGetTests.cs b/PrivaPub.Tests/Http/FederationGetTests.cs index 23b7f8a..91d6ff0 100644 --- a/PrivaPub.Tests/Http/FederationGetTests.cs +++ b/PrivaPub.Tests/Http/FederationGetTests.cs @@ -101,6 +101,20 @@ namespace PrivaPub.Tests.Http Assert.Equal("Application", instance.Json["type"]!.GetValue()); } + // PieFed reads the inbox it sends a community's announces to from the Application at a peer's root + [Fact] + public async Task The_instance_actor_answers_at_the_root_for_activitypub_only() + { + var root = await _client.Fetch("/"); + var browser = await _client.Fetch("/", Browser); + + Assert.Equal(HttpStatusCode.OK, root.Status); + Assert.Equal("Application", root.Json["type"]!.GetValue()); + Assert.Equal($"{Base}/peasants/privapub", root.Json["id"]!.GetValue()); + Assert.Equal($"{Base}/peasants/privapub/mouth", root.Json["inbox"]!.GetValue()); + Assert.Equal(HttpStatusCode.NotFound, browser.Status); + } + [Fact] public async Task Users_moves_permanently_to_peasants_and_unknown_actors_are_404() { @@ -519,6 +533,7 @@ namespace PrivaPub.Tests.Http var instance = await client.Fetch("/peasants/privapub"); Assert.Equal(HttpStatusCode.OK, instance.Status); Assert.Equal("Application", instance.Json["type"]!.GetValue()); + Assert.Equal(HttpStatusCode.OK, (await client.Fetch("/")).Status); foreach (var path in paths[..^1]) Assert.True((await client.Fetch(reader.SignedGet(path))).Status == HttpStatusCode.OK, $"{path} refused a signed GET"); // a browser is not asked for a signature: it is only sent to the public pages diff --git a/PrivaPub/Federation/Controllers/PeasantsController.cs b/PrivaPub/Federation/Controllers/PeasantsController.cs index cf482d5..a0c101e 100644 --- a/PrivaPub/Federation/Controllers/PeasantsController.cs +++ b/PrivaPub/Federation/Controllers/PeasantsController.cs @@ -74,6 +74,17 @@ namespace PrivaPub.Federation.Controllers return Activity(ActivityPubRenderer.Actor(local)); } + // The instance actor at the server's root, for whoever asks for ActivityPub there: PieFed reads the inbox it sends a + // community's announces to from the Application at a peer's root, as Lemmy serves its own, and assumes /inbox otherwise + [HttpGet, Route("/")] + public async Task Root(CancellationToken token) + { + var accept = Request.Headers.Accept.ToString(); + if (!accept.Contains("activity+json", StringComparison.OrdinalIgnoreCase) && !accept.Contains("ld+json", StringComparison.OrdinalIgnoreCase)) + return NotFound(); + return Activity(ActivityPubRenderer.Actor(await _localActors.GetInstanceActor(token))); + } + [HttpGet, Route("{actor}/anus")] public async Task Outbox(string actor, [FromQuery] bool page, [FromQuery(Name = "max_id")] string maxId, CancellationToken token) @@ -448,7 +459,7 @@ namespace PrivaPub.Federation.Controllers Response.Headers.Vary = "Accept"; // SecureMode asks every reader of ActivityPub documents for a signature, except for the instance actor, whose key // peers need first, and except for browsers, which only get redirected to the public pages - if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !WantsHtml() + if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !WantsHtml() && Request.Path.Value != "/" && !string.Equals(context.RouteData.Values["actor"] as string, LocalActorService.InstanceUserName, StringComparison.OrdinalIgnoreCase) && await _fetches.Requester(Request, HttpContext.RequestAborted) == default) { diff --git a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs index 6905ac8..5232248 100644 --- a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs @@ -198,12 +198,15 @@ namespace PrivaPub.Federation.Inbox.Handlers return; } Arrival.About(visibility: deleted.Visibility, created: deleted.CreationDate); - using (var check = await _remoteActors.FetchObject(objectUri, token)) - if (check != default && Value(System.Text.Json.Nodes.JsonNode.Parse(check.Root.GetRawText()), "type") != "Tombstone") - { - Arrival.Drop("not-deleted"); - return; - } + // a community on the post's own server speaks for it: PieFed keeps serving a thread its moderator removed. + // One elsewhere is believed once the post's server says it is gone + if (!Origin.Same(objectUri, group.ActorURI)) + using (var check = await _remoteActors.FetchObject(objectUri, token)) + if (check != default && Value(System.Text.Json.Nodes.JsonNode.Parse(check.Root.GetRawText()), "type") != "Tombstone") + { + Arrival.Drop("not-deleted"); + return; + } Arrival.Accept("removed"); await RemoteDeletes.Remove(deleted, objectUri, token); break; diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 6e41601..f8dbcb8 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -582,9 +582,19 @@ What it showed: - Mbin also auto-ingests Mastodon posts by hashtag and Announces them. - **Gaps:** - **P1:** Lemmy's P1 set, W2, and tolerating `source` as a string. - - **P2:** galleries; community polls (vote without `published`); post `Move`; `repliesEnabled`; `nsfl`; flairs; - publish `likes`/`shares` totals. + - **P2:** galleries; post `Move`; `repliesEnabled`; `nsfl`; flairs; publish `likes`/`shares` totals. - **P3:** `genAI`; accepted answers; batched Announces. +- **PieFed sends a community's announces to the inbox of the Application at a peer's root** (Lemmy's site actor), and + to `https:///inbox` when the root answers anything else. PrivaPub answered 404 there until 2026-10-05, so every + announce went to a `/inbox` it does not have; it now serves its instance actor at `/` for ActivityPub requests. +- **PieFed keeps serving a thread its moderator removed** (200, the Page unchanged), so a removal could not be checked + against the post's origin; a community on the post's own server is now believed at once (FEDERATION.md, Groups). +- **Pasture evidence (2026-10-05, PieFed 1.7.17, `tools/pasture/scenarios/piefed.sh`):** 29 checks pass: communities + both ways (follows accepted); a PieFed thread in our community arrives titled and ours reaches PieFed through its + announce; a PieFed community's thread reaches alice's home and her thread lands in it; comments both ways; PieFed's + upvote counts as a like and its change to a downvote is applied, alice's like is an upvote there; a community poll + arrives as a poll and alice's vote counts on PieFed; private messages both ways; a moderator's lock (replies then + refused), unlock and removal; the unfollow; statistics. ### NodeBB 4.16, Discourse, Friendica diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8938b17..8eb71d2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -69,6 +69,9 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati Funkwhale serve it; Funkwhale's answers named after our follow and its deletions of several uploads. - GoToSocial's interaction policies both ways; personas join and leave remote events; third-party replies to a persona's posts passed on to its followers (owner decisions 2026-10-05). + - wave 2, under way: PieFed in the pasture with a scenario (2026-10-05). What it showed and was fixed: the instance + actor answers at the server's root, where PieFed looks for the inbox it announces to; a community's removal of a + post on its own server is believed at once. - [ ] P7 Threads, communities, moderation, the social graph - [ ] P8 Signatures, discovery, the long tail - [x] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts, diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index 9536ede..736407e 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -92,3 +92,8 @@ funkwhale.test { tls internal reverse_proxy pasture-funkwhale:80 } + +piefed.test { + tls internal + reverse_proxy pasture-piefed:5000 +} diff --git a/tools/pasture/peers/piefed.sh b/tools/pasture/peers/piefed.sh new file mode 100644 index 0000000..436326e --- /dev/null +++ b/tools/pasture/peers/piefed.sh @@ -0,0 +1,65 @@ +# PieFed 1.7.17: a threadiverse server (communities, threads, comments, votes up and down, polls, flairs) in Flask, from +# dockurr's image of the release. The web app (gunicorn, with PieFed's own cron for its send queue) and a Celery worker +# share the pasture-piefed-media volume, on the shared Postgres (database piefed) and Redis (dbs 10 and 11). httpx +# trusts only certifi's bundle, so the pasture's is mounted over it. `flask init-db` asks for its admin on stdin +# (pfuser); its Lemmy-style API (/api/alpha) signs in with a JWT. +PIEFED_IMAGE=${PIEFED_IMAGE:-docker.io/dockurr/piefed:1.7.17} +PIEFED_PASSWORD=PieFed-Pasture-1 +. "$here/peers/shared.sh" + +piefed_env() { + local key="$here/.state/piefed/secret" + [ -s "$key" ] || { mkdir -p "$here/.state/piefed"; head -c 48 /dev/urandom | base64 -w0 | tr -d '/+=' > "$key"; } + cat < "$here/.state/piefed/env" + podman volume exists pasture-piefed-media || podman volume create --label pasture=1 pasture-piefed-media >/dev/null + local common=(--network $net --label pasture=1 --env-file "$here/.state/piefed/env" -v pasture-piefed-media:/app/app/static/media + -v "$ca/bundle.pem:/ca/bundle.pem:z,ro" -v "$ca/bundle.pem:/venv/lib/python3.13/site-packages/certifi/cacert.pem:z,ro") + podman run -d --replace --name pasture-piefed "${common[@]}" -e CRON=true "$PIEFED_IMAGE" >/dev/null + podman run -d --replace --name pasture-piefed-celery "${common[@]}" --entrypoint ./entrypoint_celery.sh "$PIEFED_IMAGE" >/dev/null + # the web app runs its migrations before gunicorn starts; init-db then sets the site up once, and drops every table + # it finds, so it waits for the last migration + for _ in $(seq 1 120); do + podman logs pasture-piefed 2>&1 | grep -q "Starting Gunicorn" && break + sleep 2 + done + if ! podman exec pasture-postgres psql -U pasture -d piefed -tAc "select 1 from \"user\" where user_name = 'pfuser'" 2>/dev/null | grep -q 1; then + printf 'pfuser\npfuser@piefed.test\n%s\n' "$PIEFED_PASSWORD" | podman exec -i -e FLASK_APP=pyfedi.py pasture-piefed flask init-db >/dev/null 2>&1 + podman restart pasture-piefed pasture-piefed-celery >/dev/null + fi + for _ in $(seq 1 90); do + site piefed.test -s -o /dev/null -w '%{http_code}' https://piefed.test:6443/api/alpha/site 2>/dev/null | grep -q 200 && break + sleep 2 + done + piefed_settle + echo "piefed: https://piefed.test:6443" +} + +# open registrations, no rate limit on its API for the pasture, and pfuser's JWT +piefed_settle() { + podman exec pasture-postgres psql -U pasture -d piefed -qc "update site set registration_mode = 'Open', enable_downvotes = true" >/dev/null 2>&1 || true + site piefed.test -s -X POST https://piefed.test:6443/api/alpha/user/login -H 'Content-Type: application/json' \ + -d "{\"username\":\"pfuser\",\"password\":\"$PIEFED_PASSWORD\"}" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['jwt'])" > "$here/.state/piefed.token" 2>/dev/null || true +} diff --git a/tools/pasture/run.sh b/tools/pasture/run.sh index 3947fb9..73b1cd6 100755 --- a/tools/pasture/run.sh +++ b/tools/pasture/run.sh @@ -4,7 +4,7 @@ # API at http://127.0.0.1:6971 ($PASTURE_PORT), every site at https://.test:6443 (curl --resolve .test:6443:127.0.0.1 -k). # usage: tools/pasture/run.sh up [peer...] | add | down [--purge] | logs | ps | stats # up starts everything afresh (PrivaPub republished, Mongo empty); add starts more peers next to a running pasture. -# peers: gts (default), mastodon, misskey, sharkey, akkoma, lemmy, decepub +# peers: gts (default), mastodon, misskey, sharkey, akkoma, lemmy, decepub, and every other file in peers/ set -euo pipefail . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/pasture.sh" diff --git a/tools/pasture/scenarios/piefed.sh b/tools/pasture/scenarios/piefed.sh new file mode 100644 index 0000000..afb2907 --- /dev/null +++ b/tools/pasture/scenarios/piefed.sh @@ -0,0 +1,124 @@ +# PieFed 1.7.17: the threadiverse in Flask. Communities both ways (FEP-1b12), threads with titles, comments both ways, +# votes up and down both ways, a community poll, private messages both ways, a moderator's lock and removal, +# statistics. PieFed is driven through its Lemmy-style API (/api/alpha) as pfuser. +PF=https://piefed.test:6443 +PFT=$(cat "$here/.state/piefed.token" 2>/dev/null) +# pf [json]: a PieFed API call as pfuser +pf() { + local method=$1 path=$2 body=${3:-} + if [ -n "$body" ]; then + site piefed.test -s -X "$method" "$PF/api/alpha/$path" -H "Authorization: Bearer $PFT" -H 'Content-Type: application/json' -d "$body" + else + site piefed.test -s -X "$method" "$PF/api/alpha/$path" -H "Authorization: Bearer $PFT" + fi +} +# the posts PieFed holds in a community, and the one whose ap_id is given +pf_posts() { pf GET "post/list?community_id=$1&sort=New&limit=50" | j "print(json.dumps([p['post'] for p in d.get('posts', [])]))"; } +pf_post_by_ap() { pf_posts "$1" | j "print(json.dumps(next((p for p in d if p['ap_id']=='$2'), None)))"; } +pf_comments() { pf GET "comment/list?post_id=$1&limit=50" | j "print(json.dumps([c['comment'] for c in d.get('comments', [])]))"; } + +echo "piefed" +[ -n "$PFT" ] && ok "PieFed token for pfuser" || { ko "PieFed token"; return 1; } +PT=$(privapub_token alice_piefed) +PH="Authorization: Bearer $PT" +[ -n "$PT" ] && ok "PrivaPub token for alice_piefed" || { ko "PrivaPub token for alice_piefed"; return 1; } +jwt=$(privapub_root) +alice_id=$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])") +run=$(date +%s) +dogs="pfdogs$run" +pies="pies$run" + +echo " communities" +dogs_gid=$(curl -s -X POST $P/clientapi/group/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \ + -d "{\"avatarId\":\"$alice_id\",\"userName\":\"$dogs\",\"name\":\"Pasture dogs\",\"description\":\"a PrivaPub community\",\"isCommunity\":true}" | j "print(d['id'])") +dogs_on_pf=$(pf GET "resolve_object?q=!$dogs@privapub.test" | j "print(d['community']['community']['id'])") +[ -n "$dogs_on_pf" ] && ok "PieFed resolves a PrivaPub community" || ko "PieFed cannot resolve the PrivaPub community" +pf POST community/follow "{\"community_id\":$dogs_on_pf,\"follow\":true}" >/dev/null +until_true 45 '[ "$(pf GET "community?id=$dogs_on_pf" | j "print(d[\"community_view\"][\"subscribed\"])")" = "Subscribed" ]' \ + && ok "pfuser follows the PrivaPub community (Accept arrived)" || ko "PieFed's community follow not accepted ($(pf GET "community?id=$dogs_on_pf" | j "print(d[\"community_view\"][\"subscribed\"])"))" +pies_on_pf=$(pf POST community "{\"name\":\"$pies\",\"title\":\"Pasture pies\",\"description\":\"pies of the pasture\"}" | j "print(d['community_view']['community']['id'])") +pies_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=@$pies@piefed.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$pies_on_p" ] && ok "PrivaPub resolves a PieFed community" || ko "PrivaPub cannot resolve the PieFed community" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$pies_on_p/follow" +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$pies_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \ + && ok "alice follows the PieFed community (Accept arrived)" || ko "PrivaPub's community follow not accepted" + +echo " threads" +pf_thread=$(pf POST post "{\"title\":\"A PieFed thread\",\"body\":\"posted from PieFed into PrivaPub\",\"community_id\":$dogs_on_pf}" | j "print(d['post_view']['post']['ap_id'])") +dogs_on_p=$(curl -s -H "$PH" "$P/api/v1/accounts/lookup?acct=$dogs" | j "print(d['id'])") +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/$dogs_on_p/statuses" | j "print(any(s[\"uri\"]==\"$pf_thread\" and (s.get(\"privapub\") or {}).get(\"title\")==\"A PieFed thread\" for s in d))")" = "True" ]' \ + && ok "a PieFed thread in the PrivaPub community arrives with its title" || ko "PieFed's thread missing or untitled on PrivaPub" +curl -s -o /dev/null -X POST $P/clientapi/post/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \ + -d "{\"avatarId\":\"$alice_id\",\"title\":\"A PrivaPub community thread $run\",\"text\":\"posted into our own community for PieFed\",\"groupId\":\"$dogs_gid\"}" +dogs_post=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Post.findOne({Text:/posted into our own community for PieFed/}, {}, {sort:{_id:-1}}).ObjectURI)') +until_true 45 '[ "$(pf_post_by_ap "$dogs_on_pf" "$dogs_post" | j "print(d and d[\"title\"])")" = "A PrivaPub community thread $run" ]' \ + && ok "a titled post in the PrivaPub community reaches PieFed through its announce" || ko "PrivaPub community post missing on PieFed" +pf_pies_thread=$(pf POST post "{\"title\":\"Pies only $run\",\"body\":\"a PieFed community post\",\"community_id\":$pies_on_pf}" | j "print(d['post_view']['post']['ap_id'])") +p_home_has() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next(((s.get('reblog') or s)['id'] for s in d if (s.get('reblog') or s)['uri']=='$1'), ''))"; } +until_true 45 '[ -n "$(p_home_has "$pf_pies_thread")" ]' \ + && ok "the PieFed community's announce brings its thread to alice's home" || ko "PieFed community thread missing from alice's home" +pies_thread_on_p=$(p_home_has "$pf_pies_thread") +p_thread=$(curl -s -X POST -H "$PH" $P/api/v1/statuses --data-urlencode "status=A PrivaPub thread +@$pies@piefed.test posted from PrivaPub" -d 'visibility=public') +p_thread_id=$(echo "$p_thread" | j "print(d['id'])"); p_thread_uri=$(echo "$p_thread" | j "print(d['uri'])") +until_true 45 '[ "$(pf_post_by_ap "$pies_on_pf" "$p_thread_uri")" != "null" ]' && ok "alice's thread lands in the PieFed community" || ko "PrivaPub thread missing from the PieFed community" +p_thread_on_pf=$(pf_post_by_ap "$pies_on_pf" "$p_thread_uri" | j "print(d['id'])") + +echo " comments" +pf POST comment "{\"post_id\":$p_thread_on_pf,\"body\":\"a PieFed comment on PrivaPub\"}" >/dev/null +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_thread_id/context" | j "print(any(\"a PieFed comment on PrivaPub\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \ + && ok "PieFed's comment threads under alice's post" || ko "PieFed's comment missing on PrivaPub" +curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=a PrivaPub comment on PieFed&in_reply_to_id=$pies_thread_on_p&visibility=public" +pies_thread_id=$(pf GET "resolve_object?q=$pf_pies_thread" | j "print(d['post']['post']['id'])") +until_true 45 '[ "$(pf_comments "$pies_thread_id" | j "print(any(\"a PrivaPub comment on PieFed\" in c[\"body\"] for c in d))")" = "True" ]' \ + && ok "alice's reply becomes a comment on PieFed" || ko "PrivaPub reply missing as a PieFed comment" + +echo " votes" +pf POST post/like "{\"post_id\":$p_thread_on_pf,\"score\":1}" >/dev/null +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"])")" = "1" ]' \ + && ok "PieFed's upvote counts as a like on PrivaPub" || ko "PieFed's upvote not counted" +pf POST post/like "{\"post_id\":$p_thread_on_pf,\"score\":-1}" >/dev/null +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"], ((d.get(\"privapub\") or {}).get(\"votes\") or {}).get(\"down\"))")" = "0 1" ]' \ + && ok "PieFed's change to a downvote reaches PrivaPub" || ko "PieFed's change to a downvote not applied ($(curl -s -H "$PH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"], (d.get(\"privapub\") or {}).get(\"votes\"))"))" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$pies_thread_on_p/favourite" +until_true 45 '[ "$(pf GET "post?id=$pies_thread_id" | j "print(d[\"post_view\"][\"counts\"][\"upvotes\"])")" = "2" ]' \ + && ok "alice's like is an upvote on PieFed" || ko "like not counted as an upvote on PieFed ($(pf GET "post?id=$pies_thread_id" | j "print(d[\"post_view\"][\"counts\"])"))" + +echo " polls" +ends=$(date -u -d '+2 days' +%Y-%m-%dT%H:%M:%S.000000Z) +pf_poll=$(pf POST post "{\"title\":\"Which pie $run\",\"body\":\"vote for one\",\"community_id\":$pies_on_pf,\"poll\":{\"mode\":\"single\",\"end_poll\":\"$ends\",\"choices\":[{\"id\":1,\"choice_text\":\"apple\",\"sort_order\":0},{\"id\":2,\"choice_text\":\"cherry\",\"sort_order\":1}]}}") +pf_poll_uri=$(echo "$pf_poll" | j "print(d['post_view']['post']['ap_id'])") +until_true 45 '[ -n "$(p_home_has "$pf_poll_uri")" ]' && ok "a PieFed poll reaches alice's home" || ko "the PieFed poll never arrived ($(echo "$pf_poll" | cut -c1-200))" +poll_on_p=$(p_home_has "$pf_poll_uri") +poll=$(curl -s -H "$PH" "$P/api/v1/statuses/$poll_on_p" | j "print(json.dumps(d.get('poll')))") +[ "$(echo "$poll" | j "print([o['title'] for o in d['options']])")" = "['apple', 'cherry']" ] && ok "as a poll with its choices" || ko "the PieFed poll is no poll on PrivaPub ($poll)" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/polls/$(echo "$poll" | j "print(d['id'])")/votes" -H 'Content-Type: application/json' -d '{"choices":[1]}' +pf_poll_id=$(echo "$pf_poll" | j "print(d['post_view']['post']['id'])") +until_true 60 '[ "$(pf GET "post?id=$pf_poll_id" | j "print(sum(c.get(\"num_votes\", 0) for c in d[\"post_view\"][\"post\"][\"poll\"][\"choices\"] if c[\"choice_text\"]==\"cherry\"))")" = "1" ]' \ + && ok "alice's vote counts on PieFed" || ko "alice's vote not counted on PieFed ($(pf GET "post?id=$pf_poll_id" | j "print(d[\"post_view\"][\"post\"].get(\"poll\"))"))" + +echo " private messages" +alice_on_pf=$(pf GET "resolve_object?q=@alice_piefed@privapub.test" | j "print(d['person']['person']['id'])") +pf POST private_message "{\"content\":\"a secret from PieFed\",\"recipient_id\":$alice_on_pf}" >/dev/null +until_true 45 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "a secret from PieFed"' && ok "PieFed's private message arrives as a DM" || ko "PieFed's private message missing on PrivaPub" +curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d 'status=@pfuser@piefed.test a secret from PrivaPub&visibility=direct' +until_true 45 'pf GET "private_message/list?limit=50" | grep -q "a secret from PrivaPub"' && ok "alice's DM arrives as a PieFed private message" || ko "DM missing on PieFed" + +echo " moderation" +p_locked() { curl -s -H "$PH" "$P/api/v1/statuses/$pies_thread_on_p" | j "print((d.get('privapub') or {}).get('locked'))"; } +pf POST post/lock "{\"post_id\":$pies_thread_id,\"locked\":true}" >/dev/null +until_true 45 '[ "$(p_locked)" = "True" ]' && ok "a moderator's lock reaches PrivaPub" || ko "a moderator's lock did not reach PrivaPub" +[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$PH" $P/api/v1/statuses -d "status=too late&in_reply_to_id=$pies_thread_on_p&visibility=public")" = "422" ] \ + && ok "a reply to the locked thread is refused" || ko "a reply to the locked thread was taken" +pf POST post/lock "{\"post_id\":$pies_thread_id,\"locked\":false}" >/dev/null +until_true 45 '[ "$(p_locked)" = "False" ]' && ok "a moderator's unlock reaches PrivaPub" || ko "a moderator's unlock did not reach PrivaPub" +pf POST post/remove "{\"post_id\":$pies_thread_id,\"removed\":true,\"reason\":\"pasture\"}" >/dev/null +until_true 45 '[ -z "$(p_home_has "$pf_pies_thread")" ]' && ok "a moderator's removal reaches PrivaPub" || ko "a moderator's removal did not reach PrivaPub" + +echo " unfollow" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$pies_on_p/unfollow" +until_true 45 '[ "$(podman exec pasture-postgres psql -U pasture -d piefed -tAc "select count(*) from community_member m join \"user\" u on u.id = m.user_id where u.ap_profile_id like '"'%alice_piefed%'"' and m.community_id = $pies_on_pf")" = "0" ]' \ + && ok "alice's unfollow reaches the PieFed community" || ko "the PieFed community still counts alice" + +echo " statistics" +stats_check piefed.test piefed