Pasture: scenarios run again on the same pasture, and Mastodon's threads
GoToSocial's and Mastodon's scenarios passed once on a fresh pasture only:
their locked follows ended followed, so a second run found no request to
reject, and Mastodon's ended with mastouser blocking alice_masto, so every
follow after it was rejected. Each now unfollows first, and Mastodon's
undoes its block (checking that its Undo{Block} reaches our blocked_by) and
its lock; a run cut short is undone at the start.
Mastodon's scenario also checks the thread backfill live: a reply by an
account nobody here follows is never delivered, and joins the thread once
alice_masto opens it, read from Mastodon's FEP-7888 context.
interop.sh keeps the results of the peers it does not run, so the report
merges a partial rerun. All seven scenarios: 276 pass, 0 fail, 1 expected
(Lemmy moderation, P7).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
0695c08ac3
commit
b146e182c6
5 files changed
+51
-7
No files matched your search
@@ -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
|
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/<id>`), so look URIs up rather than
|
`Status.exists?` through `rails runner`. Its actors are numbered (`/ap/users/<id>`), so look URIs up rather than
|
||||||
build them. Circle posts and a followers-only post survive its signed refetch (`ActivityPub::FetchRemoteStatusService`
|
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
|
- **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/<endpoint>`
|
(`federation: none`) until `admin/update-meta` says `all`, which `misskey_up` does. Its API is `POST /api/<endpoint>`
|
||||||
with the token as `i` (`mk` in the scenario); `users/relation` answers a list, `users/notes` leaves replies out unless
|
with the token as `i` (`mk` in the scenario); `users/relation` answers a list, `users/notes` leaves replies out unless
|
||||||
|
|||||||
+7
-3
@@ -151,10 +151,13 @@ Priorities, used throughout:
|
|||||||
| Publish `context` and a paged `replies` | P2 | — |
|
| Publish `context` and a paged `replies` | P2 | — |
|
||||||
| `FeatureRequest`: send `Reject` (or implement FEP-7aa9) | P3 | — |
|
| `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;
|
- discovery, follows and locked follows both ways;
|
||||||
- public, CW and followers-only posts (the last answering 404 unsigned);
|
- public, CW and followers-only posts (the last answering 404 unsigned);
|
||||||
- replies threading both ways;
|
- 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;
|
- likes, boosts and their undos both ways, with counts;
|
||||||
- DMs both ways;
|
- DMs both ways;
|
||||||
- polls and votes both ways;
|
- polls and votes both ways;
|
||||||
@@ -164,7 +167,7 @@ Priorities, used throughout:
|
|||||||
- deletes both ways, ours answering 410;
|
- deletes both ways, ours answering 410;
|
||||||
- a Flag reaching Mastodon's moderators from the instance actor;
|
- a Flag reaching Mastodon's moderators from the instance actor;
|
||||||
- a circle request held for its owner and approved;
|
- 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.
|
- statistics naming `mastodon.test` as mastodon with no account names.
|
||||||
|
|
||||||
Findings:
|
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
|
**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,
|
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
|
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.
|
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.
|
Such posts are then found in the member's conversations, never by a search on their URI.
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Drives PrivaPub and each peer (started by run.sh) through each other's federation, as their users would, then checks
|
# 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.
|
# 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
|
set -uo pipefail
|
||||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
. "$here/lib/interop.sh"
|
. "$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; }
|
[ -f "$here/scenarios/$peer.sh" ] || { echo "no scenario for $peer" >&2; exit 2; }
|
||||||
INTEROP_PEER=$peer
|
INTEROP_PEER=$peer
|
||||||
. "$here/scenarios/$peer.sh"
|
. "$here/scenarios/$peer.sh"
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ echo "locked personas"
|
|||||||
LT=$(privapub_token locked_alice); LH="Authorization: Bearer $LT"
|
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'
|
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'])")
|
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
|
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"
|
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"
|
[ "$(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"
|
||||||
|
|||||||
@@ -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"
|
[ -n "$masto_on_p" ] && ok "PrivaPub resolves @mastouser@mastodon.test" || ko "PrivaPub cannot resolve mastouser"
|
||||||
|
|
||||||
echo " follows"
|
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"
|
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"
|
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"
|
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'
|
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"
|
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'])")
|
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"
|
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"
|
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'))")
|
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"
|
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"
|
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"
|
echo " circles"
|
||||||
jwt=$(privapub_root)
|
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"
|
&& 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"
|
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"
|
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"
|
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"
|
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"
|
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" ]' \
|
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"
|
&& 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"
|
echo " statistics"
|
||||||
stats_check mastodon.test mastodon
|
stats_check mastodon.test mastodon
|
||||||
Reference in new issue
Block a user