Files
SocialPub/tools/pasture/interop.sh
T
thepraandClaude Opus 5.5 bb9bd71391
Build / Build (push) Successful in 50s
Deploy / privapub.thepra.dev (push) Successful in 1m4s
P5, first batch: summaries stop hiding articles, personas get reachable names, blocks federate
- A remote `summary` is a content warning only on a Note or Question, or when `sensitive` is set. On Articles,
  Events, Videos, Pages and Audio it is an excerpt (WordPress teasers, Mobilizon dates and places, Mbin titles) and
  is now kept as `Post.Excerpt` instead of hiding the post. `Post.ObjectType` records the remote type, and the
  Mastodon API shows a remote non-Note object's title above its body again.
- Persona usernames must match `^[a-z0-9_]+$`, as groups already did; a name outside it was unreachable from
  Mastodon and Misskey.
- `postingRestrictedToMods` is defined in our JSON-LD context (Iceshrimp.NET drops undefined terms).
- `Vary: Accept` on actor and object URLs.
- Owner decision: blocks federate. A block sends `Block`, an unblock `Undo{Block}`; checked live against GoToSocial.
- Owner decision: a persona's and a group's `published`, and the day in new ids, is a random day up to two weeks
  before creation, so personas made the same day no longer share a date. Migration _007 gives existing ones theirs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 17:39:58 +02:00

124 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# Drives PrivaPub and GoToSocial (started by run.sh) through each other's federation, as their users would.
set -uo pipefail
P=http://127.0.0.1:6971; G=https://gts.test:6443
gcurl() { curl -k --resolve gts.test:6443:127.0.0.1 "$@"; }
work=$(mktemp -d); trap 'rm -rf "$work"' EXIT
pass=0; fail=0
ok() { echo " ok $*"; pass=$((pass+1)); }
ko() { echo " FAIL $*"; fail=$((fail+1)); }
j() { python3 -c "import sys,json
try: d=json.load(sys.stdin)
except Exception: d=None
$1" 2>/dev/null; }
until_true() { local tries=$1; shift; for i in $(seq 1 "$tries"); do if eval "$@"; then return 0; fi; sleep 2; done; return 1; }
# GoToSocial's cached home timeline can stop taking new posts after its first read, so a delivery is checked by looking
# the object up by URI with resolve=false: that answers from GoToSocial's database and never fetches from us.
on_gts() { gcurl -s -G -H "$GH" "$G/api/v2/search" --data-urlencode "q=$1" -d resolve=false -d type=statuses; }
# --- PrivaPub persona and token
root=$(curl -s -X POST $P/clientapi/user/signup -H 'Content-Type: application/json' -d '{"userName":"pastureroot","password":"Pasture-Pass-1!"}')
jwt=$(echo "$root" | j "print(d['token'])"); rootid=$(echo "$root" | j "print(d['userId'])")
if [ -z "$jwt" ]; then jwt=$(curl -s -X POST $P/clientapi/user/login -H 'Content-Type: application/json' -d '{"userName":"pastureroot","password":"Pasture-Pass-1!"}' | j "print(d['token'])"); rootid=$(curl -s -X POST $P/clientapi/user/login -H 'Content-Type: application/json' -d '{"userName":"pastureroot","password":"Pasture-Pass-1!"}' | j "print(d['userId'])"); fi
curl -s -o /dev/null -X POST $P/clientapi/avatar/private/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"rootId\":\"$rootid\",\"userName\":\"alice\",\"name\":\"Alice of PrivaPub\",\"biography\":\"testing federation\"}"
app=$(curl -s -X POST $P/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read+write+follow')
cid=$(echo "$app" | j "print(d['client_id'])"); cs=$(echo "$app" | j "print(d['client_secret'])")
q="client_id=$cid&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=read+write+follow"
xt=$(curl -s -c $work/pj -b $work/pj "$P/oauth/login?returnUrl=/oauth/authorize?$q" | grep -o 'name="__RequestVerificationToken" type="hidden" value="[^"]*"' | sed 's/.*value="//;s/"//')
curl -s -o /dev/null -c $work/pj -b $work/pj -X POST $P/oauth/login --data-urlencode "returnUrl=/oauth/authorize?$q" --data-urlencode "__RequestVerificationToken=$xt" -d 'userName=pastureroot&password=Pasture-Pass-1!'
curl -s -c $work/pj -b $work/pj "$P/oauth/authorize?$q&signed_in=1" > $work/choose.html
form=$(python3 - "$work/choose.html" <<'PY'
import re,sys,urllib.parse,html
s=open(sys.argv[1]).read()
pairs=[(k,html.unescape(v)) for k,v in re.findall(r'<input type="hidden" name="([^"]*)" value="([^"]*)"',s)]
print(urllib.parse.urlencode(pairs+[("avatarId",re.findall(r'name="avatarId" value="([^"]*)"',s)[0]),("decision","allow")]))
PY
)
code=$(curl -s -c $work/pj -b $work/pj -X POST $P/oauth/authorize --data "$form" | grep -o '<code>[^<]*</code>' | sed 's/<[^>]*>//g')
PT=$(curl -s -X POST $P/oauth/token -d "grant_type=authorization_code&code=$code&client_id=$cid&client_secret=$cs&redirect_uri=urn:ietf:wg:oauth:2.0:oob" | j "print(d['access_token'])")
PH="Authorization: Bearer $PT"
[ -n "$PT" ] && ok "PrivaPub token for alice" || { ko "PrivaPub token"; exit 1; }
# --- GoToSocial token
gapp=$(gcurl -s -X POST $G/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read+write+follow')
gid=$(echo "$gapp" | j "print(d['client_id'])"); gs=$(echo "$gapp" | j "print(d['client_secret'])")
gcurl -s -o /dev/null -c $work/gj -b $work/gj "$G/oauth/authorize?client_id=$gid&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=read+write+follow"
gcurl -s -o /dev/null -c $work/gj -b $work/gj -X POST $G/auth/sign_in --data-urlencode 'username=gtsuser@gts.test' --data-urlencode 'password=Gts-Pasture-Pass-1!'
gcode=$(gcurl -s -o /dev/null -w '%{redirect_url}' -c $work/gj -b $work/gj -X POST "$G/oauth/authorize" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
GT=$(gcurl -s -X POST $G/oauth/token -d "grant_type=authorization_code&code=$gcode&client_id=$gid&client_secret=$gs&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=read+write+follow" | j "print(d['access_token'])")
GH="Authorization: Bearer $GT"
[ -n "$GT" ] && ok "GoToSocial token for gtsuser" || { ko "GoToSocial token (code '$gcode')"; exit 1; }
echo "discovery"
alice_on_gts=$(gcurl -s -H "$GH" "$G/api/v2/search?q=@alice@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$alice_on_gts" ] && ok "GoToSocial resolves @alice@privapub.test" || ko "GoToSocial cannot resolve alice"
gts_on_pp=$(curl -s -H "$PH" "$P/api/v2/search?q=gtsuser@gts.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$gts_on_pp" ] && ok "PrivaPub resolves @gtsuser@gts.test" || ko "PrivaPub cannot resolve gtsuser"
echo "follows"
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/accounts/$alice_on_gts/follow
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$alice_on_gts" | j "print(d[0][\"following\"])")" = "True" ]' && ok "gtsuser follows alice (Accept arrived)" || ko "gtsuser's follow of alice not accepted"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/accounts/$gts_on_pp/follow
until_true 10 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$gts_on_pp" | j "print(d[0][\"requested\"])")" = "True" ]' && ok "alice's follow of locked gtsuser waits as a request" || ko "alice's follow is not shown as requested"
until_true 20 'gcurl -s -H "$GH" "$G/api/v1/follow_requests" | j "print(any(a[\"id\"]==\"$alice_on_gts\" for a in d))" | grep -q True' && ok "the request reaches gtsuser's follow requests" || ko "follow request missing on GoToSocial"
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/follow_requests/$alice_on_gts/authorize
until_true 20 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$gts_on_pp" | j "print(d[0][\"following\"])")" = "True" ]' && ok "alice follows gtsuser (Accept arrived)" || ko "alice's follow of gtsuser not accepted"
echo "posts"
pp_status=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=Hello from PrivaPub #pasture&visibility=public')
pp_post=$(echo "$pp_status" | j "print(d['id'])"); pp_uri=$(echo "$pp_status" | j "print(d['uri'])")
until_true 20 'on_gts "$pp_uri" | j "print(len(d[\"statuses\"]))" | grep -q 1' && ok "alice's post reaches GoToSocial" || ko "alice's post missing on GoToSocial"
gts_post=$(gcurl -s -X POST -H "$GH" $G/api/v1/statuses -d 'status=Hello from GoToSocial&visibility=public' | j "print(d['id'])")
until_true 20 'curl -s -H "$PH" "$P/api/v1/timelines/home" | grep -q "Hello from GoToSocial"' && ok "gtsuser's post reaches alice's home" || ko "gtsuser's post missing on PrivaPub"
[ "$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['edited_at'] for s in d if 'Hello from GoToSocial' in s['content']))")" = "None" ] && ok "an unedited remote post carries no edited_at" || ko "unedited remote post reports an edit"
cw_status=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=behind a warning&spoiler_text=spoilers&visibility=public')
cw=$(echo "$cw_status" | j "print(d['id'])"); cw_uri=$(echo "$cw_status" | j "print(d['uri'])")
until_true 20 'on_gts "$cw_uri" | j "print(any(s[\"spoiler_text\"]==\"spoilers\" and s[\"sensitive\"] for s in d[\"statuses\"]))" | grep -q True' && ok "content warning survives to GoToSocial" || ko "content warning lost"
cw_on_gts=$(on_gts "$cw_uri" | j "print(d['statuses'][0]['id'])")
echo "replies and mentions"
pp_on_gts=$(on_gts "$pp_uri" | j "print(d['statuses'][0]['id'])")
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses -d "status=@alice@privapub.test nice to meet you&in_reply_to_id=$pp_on_gts&visibility=public"
until_true 20 'curl -s -H "$PH" "$P/api/v1/notifications" | j "print(any(n[\"type\"]==\"mention\" for n in d))" | grep -q True' && ok "gtsuser's reply notifies alice" || ko "reply did not notify alice"
until_true 10 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$pp_post/context" | j "print(len(d[\"descendants\"]))")" -ge 1 ]' && ok "the reply threads under alice's post" || ko "reply not threaded"
echo "likes and boosts"
gts_on_pp_post=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'Hello from GoToSocial' in s['content']))")
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses/$gts_on_pp_post/favourite
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses/$gts_on_pp_post/reblog
until_true 20 'gcurl -s -H "$GH" "$G/api/v1/notifications" | j "print(any(n[\"type\"]==\"favourite\" for n in d))" | grep -q True' && ok "alice's like reaches GoToSocial" || ko "like not received"
until_true 20 'gcurl -s -H "$GH" "$G/api/v1/notifications" | j "print(any(n[\"type\"]==\"reblog\" for n in d))" | grep -q True' && ok "alice's boost reaches GoToSocial" || ko "boost not received"
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses/$pp_on_gts/favourite
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses/$pp_on_gts/reblog
until_true 20 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$pp_post" | j "print(d[\"favourites_count\"])")" = "1" ]' && ok "gtsuser's like counts on PrivaPub" || ko "like not counted"
until_true 20 'curl -s -H "$PH" "$P/api/v1/notifications" | j "print(any(n[\"type\"]==\"reblog\" for n in d))" | grep -q True' && ok "gtsuser's boost notifies alice" || ko "boost not notified"
echo "direct messages"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d 'status=@gtsuser@gts.test a secret&visibility=direct'
until_true 20 'gcurl -s -H "$GH" "$G/api/v1/conversations" | grep -q "a secret"' && ok "alice's DM reaches gtsuser" || ko "DM missing on GoToSocial"
gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses -d 'status=@alice@privapub.test a secret back&visibility=direct'
until_true 20 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "a secret back"' && ok "gtsuser's DM reaches alice" || ko "DM missing on PrivaPub"
until_true 5 '! gcurl -s -H "$GH" "$G/api/v1/timelines/public?local=false" | grep -q "a secret"' && ok "the DM is not public on GoToSocial" || ko "DM leaked to a public timeline"
echo "edits and deletes"
curl -s -o /dev/null -X PUT -H "$PH" $P/api/v1/statuses/$pp_post -d 'status=Hello from PrivaPub, edited'
until_true 20 'gcurl -s -H "$GH" "$G/api/v1/statuses/$pp_on_gts" | grep -q "edited"' && ok "alice's edit reaches GoToSocial" || ko "edit not applied"
curl -s -o /dev/null -X DELETE -H "$PH" $P/api/v1/statuses/$cw
[ -n "$cw_on_gts" ] && until_true 20 '[ "$(gcurl -s -o /dev/null -w "%{http_code}" -H "$GH" "$G/api/v1/statuses/$cw_on_gts")" = "404" ]' && ok "alice's delete reaches GoToSocial" || ko "delete not applied"
gcurl -s -o /dev/null -X DELETE -H "$GH" $G/api/v1/statuses/$gts_post
until_true 20 '! curl -s -H "$PH" "$P/api/v1/timelines/home" | grep -q "Hello from GoToSocial"' && ok "gtsuser's delete reaches PrivaPub" || ko "remote delete not applied"
echo "unfollow"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/accounts/$gts_on_pp/unfollow
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$alice_on_gts" | j "print(d[0][\"followed_by\"])")" = "False" ]' && ok "alice's unfollow reaches GoToSocial" || ko "unfollow not applied"
echo "blocks"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/accounts/$gts_on_pp/block
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$alice_on_gts" | j "print(d[0][\"blocked_by\"])")" = "True" ]' && ok "alice's block reaches GoToSocial" || ko "block not applied"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/accounts/$gts_on_pp/unblock
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$alice_on_gts" | j "print(d[0][\"blocked_by\"])")" = "False" ]' && ok "alice's unblock reaches GoToSocial" || ko "unblock not applied"
echo; echo "$pass passed, $fail failed"
[ "$fail" = 0 ]