diff --git a/CLAUDE.md b/CLAUDE.md index 5f401da..b4f4bfa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -470,7 +470,8 @@ tools/pasture/run.sh down # removes e deliveries are checked through `/api/v1/accounts/:id/statuses` of the sender as Mastodon knows them, or `Status.exists?` through `rails runner`. Its actors are numbered (`/ap/users/`), so look URIs up rather than build them. Circle posts and a followers-only post survive its signed refetch (`ActivityPub::FetchRemoteStatusService` - through `rails runner`). Its block of a persona is checked through our `blocked_by`. + through `rails runner`). Its block of a persona, and the unblock that ends the run, are checked through our + `blocked_by`; the run starts with that unblock too, since Mastodon rejects every follow from an account it blocks. - **Misskey (2026.10.0):** one container on the shared Postgres and Redis. A new Misskey federates with nobody (`federation: none`) until `admin/update-meta` says `all`, which `misskey_up` does. Its API is `POST /api/` with the token as `i` (`mk` in the scenario); `users/relation` answers a list, `users/notes` leaves replies out unless diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 50260ae..943fdf4 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -151,10 +151,13 @@ Priorities, used throughout: | Publish `context` and a paged `replies` | P2 | — | | `FeatureRequest`: send `Reject` (or implement FEP-7aa9) | P3 | — | -**Pasture evidence (2026-10-03, Mastodon v4.7.3, `tools/pasture/scenarios/mastodon.sh`):** 49 checks pass. They cover: +**Pasture evidence (2026-10-05, Mastodon v4.7.3, `tools/pasture/scenarios/mastodon.sh`):** 56 checks pass, and the +scenario can be run again on the same pasture (it undoes the lock and the block it leaves). They cover: - discovery, follows and locked follows both ways; - public, CW and followers-only posts (the last answering 404 unsigned); - replies threading both ways; +- a thread completed from Mastodon's FEP-7888 `context`: a reply by an account nobody here follows, never delivered, + joins the thread once a persona opens it; - likes, boosts and their undos both ways, with counts; - DMs both ways; - polls and votes both ways; @@ -164,7 +167,7 @@ Priorities, used throughout: - deletes both ways, ours answering 410; - a Flag reaching Mastodon's moderators from the instance actor; - a circle request held for its owner and approved; -- unfollow and block; +- unfollow, block and unblock both ways (Mastodon's block and its undo reach our `blocked_by`); - statistics naming `mastodon.test` as mastodon with no account names. Findings: @@ -239,7 +242,8 @@ Findings: **Pasture evidence (2026-10-03, GoToSocial 0.22.1, `tools/pasture/scenarios/gts.sh`):** 37 checks pass, three runs in a row. That is the original 33 plus four on statistics: described as gotosocial, inbound and outbound traffic counted, -no account named. Since 2026-10-04 (v1.19.0) circle posts reach a GoToSocial member too. GoToSocial files a post for +no account named. On 2026-10-05 the scenario runs 55 checks, all passing, and can be run again on the same pasture (the +locked persona loses its follower first). Since 2026-10-04 (v1.19.0) circle posts reach a GoToSocial member too. GoToSocial files a post for neither the public nor the author's followers as a direct message, like our DMs, and shows it only to the accounts it mentions. Being in `cc` stored it but left it invisible, so each member's copy also mentions that member silently. Such posts are then found in the member's conversations, never by a search on their URI. diff --git a/tools/pasture/interop.sh b/tools/pasture/interop.sh index 6a0c206..9e91866 100755 --- a/tools/pasture/interop.sh +++ b/tools/pasture/interop.sh @@ -1,13 +1,23 @@ #!/usr/bin/env bash # Drives PrivaPub and each peer (started by run.sh) through each other's federation, as their users would, then checks # that PrivaPub's statistics saw it all without naming anyone. -# usage: tools/pasture/interop.sh [peer...] peers: gts (default), mastodon, misskey, sharkey, akkoma, lemmy, crawler +# usage: tools/pasture/interop.sh [peer...] peers: gts (default), mastodon, misskey, sharkey, akkoma, lemmy, +# peertube, crawler set -uo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" . "$here/lib/interop.sh" -: > "$INTEROP_JSONL" +peers=("${@:-gts}") +# a run replaces what the peers it runs said last time, and keeps what the others said +if [ -s "$INTEROP_JSONL" ]; then + python3 - "$INTEROP_JSONL" "${peers[@]}" <<'PY' +import json, sys +path, peers = sys.argv[1], set(sys.argv[2:]) +kept = [line for line in open(path) if line.strip() and json.loads(line).get("peer") not in peers] +open(path, "w").writelines(kept) +PY +fi -for peer in "${@:-gts}"; do +for peer in "${peers[@]}"; do [ -f "$here/scenarios/$peer.sh" ] || { echo "no scenario for $peer" >&2; exit 2; } INTEROP_PEER=$peer . "$here/scenarios/$peer.sh" diff --git a/tools/pasture/scenarios/gts.sh b/tools/pasture/scenarios/gts.sh index 3eeece8..6285cd0 100644 --- a/tools/pasture/scenarios/gts.sh +++ b/tools/pasture/scenarios/gts.sh @@ -176,6 +176,9 @@ echo "locked personas" LT=$(privapub_token locked_alice); LH="Authorization: Bearer $LT" curl -s -o /dev/null -X PATCH -H "$LH" "$P/api/v1/accounts/update_credentials" -d 'locked=true' locked_on_gts=$(gcurl -s -H "$GH" "$G/api/v2/search?q=@locked_alice@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +# gtsuser follows locked_alice once a run has passed here: unfollow first, so the follow below is a new request +gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/accounts/$locked_on_gts/unfollow +until_true 20 '[ "$(curl -s -H "$LH" "$P/api/v1/accounts/verify_credentials" | j "print(d[\"followers_count\"])")" = "0" ]' || true gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/accounts/$locked_on_gts/follow until_true 20 '[ "$(curl -s -H "$LH" "$P/api/v1/follow_requests" | j "print(len(d))")" = "1" ]' && ok "gtsuser's follow waits on a locked persona" || ko "no follow request on the locked persona" [ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$locked_on_gts" | j "print(d[0][\"requested\"])")" = "True" ] && ok "GoToSocial shows the follow as requested" || ko "GoToSocial does not show the request" diff --git a/tools/pasture/scenarios/mastodon.sh b/tools/pasture/scenarios/mastodon.sh index 4ffdef9..c529fc3 100644 --- a/tools/pasture/scenarios/mastodon.sh +++ b/tools/pasture/scenarios/mastodon.sh @@ -22,6 +22,10 @@ masto_on_p=$(curl -s -H "$AH" "$P/api/v2/search?q=mastouser@mastodon.test&resolv [ -n "$masto_on_p" ] && ok "PrivaPub resolves @mastouser@mastodon.test" || ko "PrivaPub cannot resolve mastouser" echo " follows" +# a run cut short leaves mastouser blocking alice_masto (Mastodon then rejects every follow) or locked (every follow +# waits as a request): undo both first +mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/accounts/$alice_on_m/unblock" +mcurl -o /dev/null -X PATCH -H "$MH" "$M/api/v1/accounts/update_credentials" -d 'locked=false' mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/accounts/$alice_on_m/follow" until_true 30 '[ "$(mcurl -H "$MH" "$M/api/v1/accounts/relationships?id[]=$alice_on_m" | j "print(d[0][\"following\"])")" = "True" ]' && ok "mastouser follows alice_masto (Accept arrived)" || ko "Mastodon's follow not accepted" curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$masto_on_p/follow" @@ -123,11 +127,16 @@ echo " locked follows" mcurl -o /dev/null -X PATCH -H "$MH" "$M/api/v1/accounts/update_credentials" -d 'locked=true' BT=$(privapub_token bob_masto); BH="Authorization: Bearer $BT" masto_on_p_b=$(curl -s -H "$BH" "$P/api/v2/search?q=mastouser@mastodon.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +# bob_masto follows mastouser once a run has passed here: unfollow first, so the follow below is a new request +curl -s -o /dev/null -X POST -H "$BH" "$P/api/v1/accounts/$masto_on_p_b/unfollow" +bob_on_m=$(mcurl -H "$MH" "$M/api/v2/search?q=@bob_masto@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +until_true 30 '[ "$(mcurl -H "$MH" "$M/api/v1/accounts/relationships?id[]=$bob_on_m" | j "print(d[0][\"followed_by\"])")" = "False" ]' || true curl -s -o /dev/null -X POST -H "$BH" "$P/api/v1/accounts/$masto_on_p_b/follow" until_true 30 'mcurl -H "$MH" "$M/api/v1/follow_requests" | j "print(any(a[\"acct\"]==\"bob_masto@privapub.test\" for a in d))" | grep -q True' && ok "bob_masto's follow waits as a request on locked mastouser" || ko "follow request missing on Mastodon" bob_on_m=$(mcurl -H "$MH" "$M/api/v1/follow_requests" | j "print(next(a['id'] for a in d if a['acct']=='bob_masto@privapub.test'))") mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/follow_requests/$bob_on_m/authorize" until_true 30 '[ "$(curl -s -H "$BH" "$P/api/v1/accounts/relationships?id[]=$masto_on_p_b" | j "print(d[0][\"following\"])")" = "True" ]' && ok "mastouser's approval reaches PrivaPub" || ko "approval not applied on PrivaPub" +mcurl -o /dev/null -X PATCH -H "$MH" "$M/api/v1/accounts/update_credentials" -d 'locked=false' echo " circles" jwt=$(privapub_root) @@ -161,6 +170,20 @@ alice_on_m_o=$(mcurl -H "Authorization: Bearer $OT" "$M/api/v2/search?q=@alice_m && ok "another Mastodon user does not see the circle post" || ko "the circle post leaked to a non-member" unserved "$circle_uri" && ok "the circle post is not served unsigned" || ko "circle post served unsigned" +echo " threads" +# a reply from an account nobody here follows is never delivered to PrivaPub; opening the thread has PrivaPub read +# the thread's context collection (FEP-7888) and bring it in +t_root=$(mcurl -X POST -H "$MH" "$M/api/v1/statuses" -d "status=a thread for elsewhere $circle_name&visibility=public" | j "print(d['id'])") +until_true 30 'curl -s -H "$AH" "$P/api/v1/timelines/home" | grep -q "a thread for elsewhere $circle_name"' || true +t_root_on_p=$(curl -s -H "$AH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'a thread for elsewhere $circle_name' in s['content']))") +mcurl -o /dev/null -X POST -H "Authorization: Bearer $OT" "$M/api/v1/statuses" -d "status=said where PrivaPub does not listen $circle_name&in_reply_to_id=$t_root&visibility=public" +sleep 5 +[ "$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval "print(db.Post.countDocuments({Text:/said where PrivaPub does not listen $circle_name/}))")" = "0" ] \ + && ok "an outsider's reply is not delivered to PrivaPub" || ko "the outsider's reply was delivered (the check below proves nothing)" +curl -s -o /dev/null -H "$AH" "$P/api/v1/statuses/$t_root_on_p/context" +until_true 30 'curl -s -H "$AH" "$P/api/v1/statuses/$t_root_on_p/context" | grep -q "said where PrivaPub does not listen $circle_name"' \ + && ok "opening the thread brings the outsider's reply from Mastodon's context" || ko "the thread never got the outsider's reply" + echo " reports" curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/reports" -d "account_id=$masto_on_p&comment=pasture-report-$circle_name&forward=true" until_true 30 'mcurl -H "$MH" "$M/api/v1/admin/reports" | grep -q "pasture-report-$circle_name"' && ok "a report reaches Mastodon's moderators" || ko "report missing on Mastodon" @@ -176,6 +199,9 @@ curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$masto_on_p/unblock" mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/accounts/$alice_on_m/block" until_true 30 '[ "$(curl -s -H "$AH" "$P/api/v1/accounts/relationships?id[]=$masto_on_p" | j "print(d[0][\"blocked_by\"])")" = "True" ]' \ && ok "Mastodon's block reaches PrivaPub (blocked_by)" || ko "Mastodon's block not recorded on PrivaPub" +mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/accounts/$alice_on_m/unblock" +until_true 30 '[ "$(curl -s -H "$AH" "$P/api/v1/accounts/relationships?id[]=$masto_on_p" | j "print(d[0][\"blocked_by\"])")" = "False" ]' \ + && ok "Mastodon's unblock reaches PrivaPub" || ko "Mastodon's unblock not applied on PrivaPub" echo " statistics" stats_check mastodon.test mastodon