From c301f0c49813926933dd926f10d19a9b2ac4dd8e Mon Sep 17 00:00:00 2001 From: thepra Date: Sat, 3 Oct 2026 11:42:03 +0200 Subject: [PATCH] T9: the pasture as plugins tools/pasture/run.sh up [peer...] | down | logs | ps and interop.sh [peer...] are now built from parts: - lib/pasture.sh: network, Caddy with its CA in a volume and copied to .ca/root.crt, Mongo, PrivaPub; - peers/.sh: each peer's _up; - lib/interop.sh: ok, ko, and xf for checks expected to fail until a later phase; privapub_token , which gives each peer its own persona under one root; and stats_check; - scenarios/.sh: each peer's checks. GoToSocial is pinned at 0.22.1, the version the 33 checks were proven on. Its scenario gains four statistics checks: - the admin statistics describe gts.test as gotosocial; - they count inbound and outbound traffic; - they name no account. 37/37 passed three runs in a row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2 --- .gitignore | 2 + tools/pasture/interop.sh | 150 +++------------------------------ tools/pasture/lib/interop.sh | 70 +++++++++++++++ tools/pasture/lib/pasture.sh | 36 ++++++++ tools/pasture/peers/gts.sh | 18 ++++ tools/pasture/run.sh | 55 ++++-------- tools/pasture/scenarios/gts.sh | 115 +++++++++++++++++++++++++ 7 files changed, 269 insertions(+), 177 deletions(-) create mode 100644 tools/pasture/lib/interop.sh create mode 100644 tools/pasture/lib/pasture.sh create mode 100644 tools/pasture/peers/gts.sh create mode 100644 tools/pasture/scenarios/gts.sh diff --git a/.gitignore b/.gitignore index 879cc77..6b5da7d 100644 --- a/.gitignore +++ b/.gitignore @@ -403,3 +403,5 @@ PrivaPub/media-store/ PrivaPub/media-store-proxy/ tools/pasture/.publish/ .claude/worktrees/ +tools/pasture/.ca/ +tools/pasture/.state/ diff --git a/tools/pasture/interop.sh b/tools/pasture/interop.sh index a90d08f..20a269a 100755 --- a/tools/pasture/interop.sh +++ b/tools/pasture/interop.sh @@ -1,145 +1,15 @@ #!/usr/bin/env bash -# Drives PrivaPub and GoToSocial (started by run.sh) through each other's federation, as their users would. +# 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) 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; } +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$here/lib/interop.sh" -# --- 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'[^<]*' | 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; } +for peer in "${@:-gts}"; do + [ -f "$here/scenarios/$peer.sh" ] || { echo "no scenario for $peer" >&2; exit 2; } + . "$here/scenarios/$peer.sh" +done -# --- 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 "polls" -pp_poll=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=tea or coffee&visibility=public&poll[options][]=tea&poll[options][]=coffee&poll[expires_in]=3600' | j "print(d['uri'])") -until_true 20 'on_gts "$pp_poll" | j "print(len(d[\"statuses\"][0][\"poll\"][\"options\"]))" | grep -q 2' && ok "alice's poll reaches GoToSocial as a poll" || ko "poll missing on GoToSocial" -pp_poll_on_gts=$(on_gts "$pp_poll" | j "print(d['statuses'][0]['poll']['id'])") -gcurl -s -o /dev/null -X POST -H "$GH" "$G/api/v1/polls/$pp_poll_on_gts/votes" -d 'choices[]=1' -pp_poll_id=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if s['poll'] and s['uri']=='$pp_poll'))") -until_true 20 '[ "$(curl -s -H "$PH" "$P/api/v1/polls/$pp_poll_id" | j "print(d[\"options\"][1][\"votes_count\"])")" = "1" ]' && ok "gtsuser's vote counts on PrivaPub" || ko "vote not counted on PrivaPub" -gts_poll=$(gcurl -s -X POST -H "$GH" $G/api/v1/statuses -d 'status=red or blue&visibility=public&poll[options][]=red&poll[options][]=blue&poll[expires_in]=3600' | j "print(d['id'])") -until_true 20 'curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(any(s[\"poll\"] and s[\"content\"].find(\"red or blue\")>=0 for s in d))" | grep -q True' && ok "gtsuser's poll reaches alice as a poll" || ko "poll missing on PrivaPub" -gts_poll_on_pp=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['poll']['id'] for s in d if s['poll'] and 'red or blue' in s['content']))") -curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/polls/$gts_poll_on_pp/votes" -d 'choices[]=0' -until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/statuses/$gts_poll" | j "print(d[\"poll\"][\"options\"][0][\"votes_count\"])")" = "1" ]' && ok "alice's vote counts on GoToSocial" || ko "vote not counted on GoToSocial" - -echo "quotes" -quote_target=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'red or blue' in s['content']))") -[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$PH" $P/api/v1/statuses -d "status=quoting"ed_status_id=$quote_target")" = "422" ] \ - && ok "GoToSocial's author-only quote policy is respected" || ko "quoted a post whose author forbids it" - -echo "link previews" -linked=$(curl -s -X POST -H "$PH" $P/api/v1/statuses --data-urlencode 'status=have a look https://gts.test/@gtsuser' -d 'visibility=public' | j "print(d['id'])") -until_true 45 '[ -n "$(curl -s -H "$PH" "$P/api/v1/statuses/$linked" | j "print((d[\"card\"] or {}).get(\"title\") or \"\")")" ]' && ok "the server builds a card for a linked page" || ko "no card for the linked page" - -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" +echo; echo "$pass passed, $fail failed, $expected expected failures" [ "$fail" = 0 ] diff --git a/tools/pasture/lib/interop.sh b/tools/pasture/lib/interop.sh new file mode 100644 index 0000000..acfeb30 --- /dev/null +++ b/tools/pasture/lib/interop.sh @@ -0,0 +1,70 @@ +# Shared by interop.sh and the scenarios: check helpers, PrivaPub personas and tokens, the statistics check. +P=http://127.0.0.1:6971 +work=$(mktemp -d); trap 'rm -rf "$work"' EXIT +pass=0; fail=0; expected=0 +ok() { echo " ok $*"; pass=$((pass+1)); } +ko() { echo " FAIL $*"; fail=$((fail+1)); } +xf() { echo " xf $* (expected to fail until a later phase)"; expected=$((expected+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 _ in $(seq 1 "$tries"); do if eval "$@"; then return 0; fi; sleep 2; done; return 1; } +site() { curl -k --resolve "$1:6443:127.0.0.1" "${@:2}"; } + +ROOT_USER=pastureroot; ROOT_PASS='Pasture-Pass-1!' +privapub_root() { + local root + root=$(curl -s -X POST $P/clientapi/user/signup -H 'Content-Type: application/json' -d "{\"userName\":\"$ROOT_USER\",\"password\":\"$ROOT_PASS\"}") + [ -n "$(echo "$root" | j "print(d['token'])")" ] || root=$(curl -s -X POST $P/clientapi/user/login -H 'Content-Type: application/json' -d "{\"userName\":\"$ROOT_USER\",\"password\":\"$ROOT_PASS\"}") + echo "$root" | j "print(d['token'])" +} + +# privapub_token : creates the persona under the pasture root if needed and returns a Mastodon token for it. +privapub_token() { + local persona=$1 jwt cid cs q xt form code + jwt=$(privapub_root) + curl -s -o /dev/null -X POST $P/clientapi/avatar/private/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \ + -d "{\"userName\":\"$persona\",\"name\":\"$persona of PrivaPub\",\"biography\":\"testing federation\"}" + local app; 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" + local jar="$work/jar-$persona" + xt=$(curl -s -c "$jar" -b "$jar" "$P/oauth/login?returnUrl=/oauth/authorize?$q" | grep -o 'name="__RequestVerificationToken" type="hidden" value="[^"]*"' | sed 's/.*value="//;s/"//') + curl -s -o /dev/null -c "$jar" -b "$jar" -X POST $P/oauth/login --data-urlencode "returnUrl=/oauth/authorize?$q" --data-urlencode "__RequestVerificationToken=$xt" \ + --data-urlencode "userName=$ROOT_USER" --data-urlencode "password=$ROOT_PASS" + curl -s -c "$jar" -b "$jar" "$P/oauth/authorize?$q&signed_in=1" > "$work/choose-$persona.html" + form=$(python3 - "$work/choose-$persona.html" "$persona" <<'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']*>(.*?)',s,re.S) +avatar=None +for b in blocks: + if '@'+sys.argv[2]+'@' in b or '@'+sys.argv[2]+'<' in b or '>'+sys.argv[2]+'<' in b: + m=re.search(r'name="avatarId" value="([^"]*)"',b) + if m: avatar=m.group(1) +if avatar is None: + avatar=re.findall(r'name="avatarId" value="([^"]*)"',s)[0] +print(urllib.parse.urlencode(pairs+[("avatarId",avatar),("decision","allow")])) +PY +) + code=$(curl -s -c "$jar" -b "$jar" -X POST $P/oauth/authorize --data "$form" | grep -o '[^<]*' | sed 's/<[^>]*>//g') + 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'])" +} + +# stats_check : the admin statistics name the peer's software and count traffic both ways. +stats_check() { + local host=$1 software=$2 admin found + podman exec -w /app pasture-privapub /app/PrivaPub admin promote "$ROOT_USER" >/dev/null 2>&1 || true + admin=$(curl -s -X POST $P/clientapi/user/login -H 'Content-Type: application/json' -d "{\"userName\":\"$ROOT_USER\",\"password\":\"$ROOT_PASS\"}" | j "print(d['token'])") + until_true 30 'found=$(curl -s -H "Authorization: Bearer $admin" "$P/clientapi/admin/statistics/hosts/$host?days=1"); [ "$(echo "$found" | j "print((d[\"instance\"] or {}).get(\"software\"))")" = "$software" ]' \ + && ok "statistics describe $host as $software" || ko "statistics do not describe $host as $software" + found=$(curl -s -H "Authorization: Bearer $admin" "$P/clientapi/admin/statistics/hosts/$host?days=1") + [ "$(echo "$found" | j "print(any(k.startswith('in:') for day in d['days'] for k in day['counters']))")" = "True" ] \ + && ok "statistics count what $host sent" || ko "no inbound statistics for $host" + [ "$(echo "$found" | j "print(any(k.startswith('out:') and ':ok' in k for day in d['days'] for k in day['counters']))")" = "True" ] \ + && ok "statistics count what we delivered to $host" || ko "no outbound statistics for $host" + [ "$(echo "$found" | j "print('$ROOT_USER' not in json.dumps(d['events']) and 'alice' not in json.dumps(d['events']))")" = "True" ] \ + && ok "statistics for $host name no account" || ko "statistics for $host name an account" +} diff --git a/tools/pasture/lib/pasture.sh b/tools/pasture/lib/pasture.sh new file mode 100644 index 0000000..a2c0594 --- /dev/null +++ b/tools/pasture/lib/pasture.sh @@ -0,0 +1,36 @@ +# Shared by run.sh: the network, Caddy (its CA kept in a volume and copied to .ca/root.crt), Mongo and PrivaPub. +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; repo="$(cd "$here/../.." && pwd)" +net=privapub-pasture; publish="$here/.publish"; ca="$here/.ca" + +wait_http() { # url tries + for _ in $(seq 1 "${2:-60}"); do curl -fsk -o /dev/null "$1" && return 0; sleep 2; done + echo "timed out waiting for $1" >&2; return 1 +} + +pasture_base_up() { + local dotnet=${DOTNET:-$(command -v dotnet || echo ~/.dotnet/dotnet)} + "$dotnet" publish "$repo/PrivaPub/PrivaPub.csproj" -c Release -r linux-x64 --self-contained true -o "$publish" -v quiet + cp "$here/appsettings.Pasture.json" "$publish/" + podman network exists $net || podman network create $net >/dev/null + podman volume exists pasture-caddy-data || podman volume create pasture-caddy-data >/dev/null + podman run -d --replace --name pasture-mongo --network $net --network-alias mongo docker.io/library/mongo:8 --quiet >/dev/null + local aliases="" + for site in privapub gts mastodon akkoma misskey sharkey lemmy; do aliases="$aliases --network-alias $site.test"; done + # shellcheck disable=SC2086 + podman run -d --replace --name pasture-caddy --network $net $aliases \ + -p 127.0.0.1:6443:443 --sysctl net.ipv4.ip_unprivileged_port_start=0 -v "$here/Caddyfile:/etc/caddy/Caddyfile:Z,ro" \ + -v pasture-caddy-data:/data docker.io/library/caddy:2 >/dev/null + podman run -d --replace --name pasture-privapub --network $net -p 127.0.0.1:6971:80 \ + --sysctl net.ipv4.ip_unprivileged_port_start=0 -e ASPNETCORE_ENVIRONMENT=Pasture -w /app -v "$publish:/app:Z,ro" \ + mcr.microsoft.com/dotnet/runtime-deps:10.0 /app/PrivaPub >/dev/null + wait_http http://127.0.0.1:6971/build.json + mkdir -p "$ca" + for _ in $(seq 1 30); do podman cp pasture-caddy:/data/caddy/pki/authorities/local/root.crt "$ca/root.crt" 2>/dev/null && break; sleep 1; done +} + +pasture_down() { + podman ps -a --format '{{.Names}}' | grep '^pasture-' | xargs -r podman rm -f >/dev/null 2>&1 || true + podman volume ls --format '{{.Name}}' | grep '^pasture-' | xargs -r podman volume rm -f >/dev/null 2>&1 || true + podman network rm $net >/dev/null 2>&1 || true + rm -rf "$here/.state" +} diff --git a/tools/pasture/peers/gts.sh b/tools/pasture/peers/gts.sh new file mode 100644 index 0000000..e42eb89 --- /dev/null +++ b/tools/pasture/peers/gts.sh @@ -0,0 +1,18 @@ +# GoToSocial: sqlite, told to accept Caddy's certificate and to reach private addresses. +GTS_IMAGE=docker.io/superseriousbusiness/gotosocial:0.22.1 + +gts_up() { + podman run -d --replace --name pasture-gts --network $net -p 127.0.0.1:6972:80 \ + --sysctl net.ipv4.ip_unprivileged_port_start=0 \ + -e GTS_HOST=gts.test -e GTS_PROTOCOL=https -e GTS_PORT=80 -e GTS_BIND_ADDRESS=0.0.0.0 -e GTS_HTTP_CLIENT_TLS_INSECURE_SKIP_VERIFY=true \ + -e GTS_DB_TYPE=sqlite -e GTS_DB_ADDRESS=/gotosocial/storage/sqlite.db -e GTS_STORAGE_LOCAL_BASE_PATH=/gotosocial/storage \ + -e GTS_LETSENCRYPT_ENABLED=false -e GTS_HTTP_CLIENT_ALLOW_IPS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 \ + -e GTS_TRUSTED_PROXIES=0.0.0.0/0 -e GTS_LOG_LEVEL=info -e GTS_INSTANCE_EXPOSE_PUBLIC_TIMELINE=true \ + $GTS_IMAGE >/dev/null + wait_http http://127.0.0.1:6972/api/v1/instance + podman exec pasture-gts /gotosocial/gotosocial admin account create --username gtsuser --email gtsuser@gts.test --password 'Gts-Pasture-Pass-1!' >/dev/null 2>&1 || true + podman exec pasture-gts /gotosocial/gotosocial admin account confirm --username gtsuser >/dev/null 2>&1 || true + podman restart pasture-gts >/dev/null + wait_http http://127.0.0.1:6972/api/v1/instance + echo "gotosocial: https://gts.test:6443" +} diff --git a/tools/pasture/run.sh b/tools/pasture/run.sh index 668ae11..df00a35 100755 --- a/tools/pasture/run.sh +++ b/tools/pasture/run.sh @@ -1,44 +1,25 @@ #!/usr/bin/env bash -# A private test fediverse on one podman network: PrivaPub (privapub.test) and GoToSocial (gts.test) behind Caddy, -# whose internal CA both sides are told to accept. From the workstation: PrivaPub's API at http://127.0.0.1:6971, -# both sites at https://{privapub,gts}.test:6443 (curl --resolve ...:6443:127.0.0.1 -k). -# usage: tools/pasture/run.sh up | down | logs +# A private test fediverse on one podman network: PrivaPub (privapub.test) and the peers asked for, behind one Caddy +# whose internal CA every side is told to accept (its root is copied to .ca/root.crt). From the workstation: PrivaPub's +# API at http://127.0.0.1:6971, every site at https://.test:6443 (curl --resolve .test:6443:127.0.0.1 -k). +# usage: tools/pasture/run.sh up [peer...] | down | logs | ps peers: gts (default) set -euo pipefail -here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; repo="$(cd "$here/../.." && pwd)" -net=privapub-pasture; publish="$here/.publish" +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/pasture.sh" case "${1:-up}" in up) - dotnet=${DOTNET:-$(command -v dotnet || echo ~/.dotnet/dotnet)} - "$dotnet" publish "$repo/PrivaPub/PrivaPub.csproj" -c Release -r linux-x64 --self-contained true -o "$publish" -v quiet - cp "$here/appsettings.Pasture.json" "$publish/" - podman network exists $net || podman network create $net >/dev/null - podman run -d --replace --name pasture-mongo --network $net --network-alias mongo docker.io/library/mongo:8 --quiet >/dev/null - podman run -d --replace --name pasture-caddy --network $net --network-alias privapub.test --network-alias gts.test \ - -p 127.0.0.1:6443:443 --sysctl net.ipv4.ip_unprivileged_port_start=0 -v "$here/Caddyfile:/etc/caddy/Caddyfile:Z,ro" \ - docker.io/library/caddy:2 >/dev/null - podman run -d --replace --name pasture-privapub --network $net -p 127.0.0.1:6971:80 \ - --sysctl net.ipv4.ip_unprivileged_port_start=0 -e ASPNETCORE_ENVIRONMENT=Pasture -w /app -v "$publish:/app:Z,ro" \ - mcr.microsoft.com/dotnet/runtime-deps:10.0 /app/PrivaPub >/dev/null - podman run -d --replace --name pasture-gts --network $net -p 127.0.0.1:6972:80 \ - --sysctl net.ipv4.ip_unprivileged_port_start=0 \ - -e GTS_HOST=gts.test -e GTS_PROTOCOL=https -e GTS_PORT=80 -e GTS_BIND_ADDRESS=0.0.0.0 -e GTS_HTTP_CLIENT_TLS_INSECURE_SKIP_VERIFY=true \ - -e GTS_DB_TYPE=sqlite -e GTS_DB_ADDRESS=/gotosocial/storage/sqlite.db -e GTS_STORAGE_LOCAL_BASE_PATH=/gotosocial/storage \ - -e GTS_LETSENCRYPT_ENABLED=false -e GTS_HTTP_CLIENT_ALLOW_IPS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 \ - -e GTS_TRUSTED_PROXIES=0.0.0.0/0 -e GTS_LOG_LEVEL=info -e GTS_INSTANCE_EXPOSE_PUBLIC_TIMELINE=true \ - docker.io/superseriousbusiness/gotosocial:latest >/dev/null - for i in $(seq 1 60); do curl -fs -o /dev/null http://127.0.0.1:6971/build.json && curl -fs -o /dev/null http://127.0.0.1:6972/api/v1/instance && break; sleep 2; done - podman exec pasture-gts /gotosocial/gotosocial admin account create --username gtsuser --email gtsuser@gts.test --password 'Gts-Pasture-Pass-1!' >/dev/null 2>&1 || true - podman exec pasture-gts /gotosocial/gotosocial admin account confirm --username gtsuser >/dev/null 2>&1 || true - podman restart pasture-gts >/dev/null - for i in $(seq 1 60); do curl -fs -o /dev/null http://127.0.0.1:6972/api/v1/instance && break; sleep 2; done - echo "privapub: http://127.0.0.1:6971, https://privapub.test:6443 gotosocial: https://gts.test:6443" - ;; -down) - podman rm -f pasture-caddy pasture-privapub pasture-gts pasture-mongo >/dev/null 2>&1 || true - podman network rm $net >/dev/null 2>&1 || true - ;; -logs) - podman logs --tail 200 "pasture-${2:-privapub}" + shift || true + peers=("${@:-gts}") + pasture_base_up + for peer in "${peers[@]}"; do + [ -f "$here/peers/$peer.sh" ] || { echo "no such peer: $peer" >&2; exit 1; } + . "$here/peers/$peer.sh" + "${peer}_up" + done + echo "privapub: http://127.0.0.1:6971, https://privapub.test:6443" ;; +down) pasture_down ;; +logs) podman logs --tail 200 "pasture-${2:-privapub}" ;; +ps) podman ps --filter name=pasture- --format '{{.Names}}\t{{.Status}}' ;; +*) echo "usage: $0 up [peer...] | down | logs | ps" >&2; exit 2 ;; esac diff --git a/tools/pasture/scenarios/gts.sh b/tools/pasture/scenarios/gts.sh new file mode 100644 index 0000000..dd0a96d --- /dev/null +++ b/tools/pasture/scenarios/gts.sh @@ -0,0 +1,115 @@ +# GoToSocial 0.22: the original 33 checks, both sides driven through their Mastodon APIs. +G=https://gts.test:6443 +gcurl() { curl -k --resolve gts.test:6443:127.0.0.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; } + +echo "gotosocial" +PT=$(privapub_token alice) +PH="Authorization: Bearer $PT" +[ -n "$PT" ] && ok "PrivaPub token for alice" || { ko "PrivaPub token"; return 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')"; return 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 "polls" +pp_poll=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=tea or coffee&visibility=public&poll[options][]=tea&poll[options][]=coffee&poll[expires_in]=3600' | j "print(d['uri'])") +until_true 20 'on_gts "$pp_poll" | j "print(len(d[\"statuses\"][0][\"poll\"][\"options\"]))" | grep -q 2' && ok "alice's poll reaches GoToSocial as a poll" || ko "poll missing on GoToSocial" +pp_poll_on_gts=$(on_gts "$pp_poll" | j "print(d['statuses'][0]['poll']['id'])") +gcurl -s -o /dev/null -X POST -H "$GH" "$G/api/v1/polls/$pp_poll_on_gts/votes" -d 'choices[]=1' +pp_poll_id=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if s['poll'] and s['uri']=='$pp_poll'))") +until_true 20 '[ "$(curl -s -H "$PH" "$P/api/v1/polls/$pp_poll_id" | j "print(d[\"options\"][1][\"votes_count\"])")" = "1" ]' && ok "gtsuser's vote counts on PrivaPub" || ko "vote not counted on PrivaPub" +gts_poll=$(gcurl -s -X POST -H "$GH" $G/api/v1/statuses -d 'status=red or blue&visibility=public&poll[options][]=red&poll[options][]=blue&poll[expires_in]=3600' | j "print(d['id'])") +until_true 20 'curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(any(s[\"poll\"] and s[\"content\"].find(\"red or blue\")>=0 for s in d))" | grep -q True' && ok "gtsuser's poll reaches alice as a poll" || ko "poll missing on PrivaPub" +gts_poll_on_pp=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['poll']['id'] for s in d if s['poll'] and 'red or blue' in s['content']))") +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/polls/$gts_poll_on_pp/votes" -d 'choices[]=0' +until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/statuses/$gts_poll" | j "print(d[\"poll\"][\"options\"][0][\"votes_count\"])")" = "1" ]' && ok "alice's vote counts on GoToSocial" || ko "vote not counted on GoToSocial" + +echo "quotes" +quote_target=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'red or blue' in s['content']))") +[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$PH" $P/api/v1/statuses -d "status=quoting"ed_status_id=$quote_target")" = "422" ] \ + && ok "GoToSocial's author-only quote policy is respected" || ko "quoted a post whose author forbids it" + +echo "link previews" +linked=$(curl -s -X POST -H "$PH" $P/api/v1/statuses --data-urlencode 'status=have a look https://gts.test/@gtsuser' -d 'visibility=public' | j "print(d['id'])") +until_true 45 '[ -n "$(curl -s -H "$PH" "$P/api/v1/statuses/$linked" | j "print((d[\"card\"] or {}).get(\"title\") or \"\")")" ]' && ok "the server builds a card for a linked page" || ko "no card for the linked page" + +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 "statistics" +stats_check gts.test gotosocial