diff --git a/CLAUDE.md b/CLAUDE.md index 2c1ae90..d866d53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -410,6 +410,10 @@ 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. 49 checks; circle posts are an expected failure (see `docs/INTEROP.md`, Mastodon). +- **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 + asked, and a user has one reaction per note. Never verify a delivery with `ap/show`: it fetches. 35 checks. - **Crawler:** `PRIVAPUB_ENV="Statistics__Crawler__Enabled=true Statistics__Crawler__Seeds__0=mastodon.test" run.sh up mastodon`, then `interop.sh crawler`. `PRIVAPUB_ENV` passes any setting to the PrivaPub container. - `run.sh up` replaces every container, Mongo included, so each run starts clean. To keep the data, republish into diff --git a/docs/INTEROP.md b/docs/INTEROP.md index c39bc5d..3e2c37f 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -310,6 +310,21 @@ Firefish is dead (its site has answered 410 since February 2025). | Per-attachment `sensitive`; `isCat` and other actor extras; enforce `requireSignin…` and `makeNotes…Before` on our public pages | P2 | own `privapub.*` | | Quotes: when we send one, send every key (`quote`, `_misskey_quote`, `quoteUrl`, `quoteUri`, a FEP-e232 tag, the `RE:` fallback) | P2 | — | +**Pasture evidence (2026-10-03, Misskey 2026.10.0, `tools/pasture/scenarios/misskey.sh`):** 35 checks pass: +- discovery and follows both ways; +- posts, CW and the MFM `source` kept; +- replies threading both ways; +- 👍 and 🎉 reactions both ways, and their withdrawal; +- a like counting as a reaction; +- legacy quotes both ways (`_misskey_quote` in, `renoteId` out); +- polls and votes both ways; +- `specified` notes as DMs both ways; +- images with alt text (`comment`) both ways; +- deletes both ways, unfollow and block; +- statistics. + +A new Misskey 2026 starts with `federation: none`. + ### Pleroma 2.10.2 and Akkoma 3.20.1 **Emits** diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index 528e9fa..873a78e 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -17,3 +17,18 @@ mastodon.test { tls internal reverse_proxy pasture-mastodon:3000 } + +misskey.test { + tls internal + reverse_proxy pasture-misskey:3000 +} + +sharkey.test { + tls internal + reverse_proxy pasture-sharkey:3000 +} + +lemmy.test { + tls internal + reverse_proxy pasture-lemmy:8536 +} diff --git a/tools/pasture/lib/pasture.sh b/tools/pasture/lib/pasture.sh index ca1bda4..f4a4d3c 100644 --- a/tools/pasture/lib/pasture.sh +++ b/tools/pasture/lib/pasture.sh @@ -2,6 +2,8 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; repo="$(cd "$here/../.." && pwd)" net=privapub-pasture; publish="$here/.publish"; ca="$here/.ca" +site() { curl -k --resolve "$1:6443:127.0.0.1" "${@:2}"; } + 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 diff --git a/tools/pasture/peers/misskey.sh b/tools/pasture/peers/misskey.sh new file mode 100644 index 0000000..ef940ef --- /dev/null +++ b/tools/pasture/peers/misskey.sh @@ -0,0 +1,48 @@ +# Misskey: one container on the shared Postgres (database misskey) and Redis (db 2). The first account, made with the +# setup password, is the scenario's user; its token is kept in .state/misskey.token. +MISSKEY_IMAGE=${MISSKEY_IMAGE:-docker.io/misskey/misskey:2026.10.0} +MISSKEY_NAME=${MISSKEY_NAME:-misskey} +. "$here/peers/shared.sh" + +misskey_up() { + shared_postgres_up + shared_redis_up + podman exec pasture-postgres sh -c "psql -U pasture -tc \"select 1 from pg_database where datname='$MISSKEY_NAME'\" | grep -q 1 || createdb -U pasture $MISSKEY_NAME" + mkdir -p "$here/.state/$MISSKEY_NAME" + cat > "$here/.state/$MISSKEY_NAME/default.yml" </dev/null + for _ in $(seq 1 120); do + site "$MISSKEY_NAME.test" -s -o /dev/null -w '%{http_code}' -X POST "https://$MISSKEY_NAME.test:6443/api/meta" -H 'Content-Type: application/json' -d '{}' 2>/dev/null | grep -q 200 && break + sleep 3 + done + site "$MISSKEY_NAME.test" -s -X POST "https://$MISSKEY_NAME.test:6443/api/admin/accounts/create" -H 'Content-Type: application/json' \ + -d '{"username":"mkuser","password":"Misskey-Pasture-Pass-1","setupPassword":"pasture-setup"}' \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])" > "$here/.state/$MISSKEY_NAME.token" 2>/dev/null || true + # a new Misskey federates with nobody (federation: none) until its admin says otherwise + site "$MISSKEY_NAME.test" -s -o /dev/null -X POST "https://$MISSKEY_NAME.test:6443/api/admin/update-meta" -H 'Content-Type: application/json' \ + -d "{\"i\":\"$(cat "$here/.state/$MISSKEY_NAME.token")\",\"federation\":\"all\"}" + echo "$MISSKEY_NAME: https://$MISSKEY_NAME.test:6443" +} diff --git a/tools/pasture/scenarios/misskey.sh b/tools/pasture/scenarios/misskey.sh new file mode 100644 index 0000000..de85b1a --- /dev/null +++ b/tools/pasture/scenarios/misskey.sh @@ -0,0 +1,124 @@ +# Misskey 2026.10 (or Sharkey, with MISSKEY_NAME=sharkey): discovery, follows, posts and CW, replies both ways, Misskey +# reactions both ways, legacy quotes both ways, MFM source, polls, specified notes, media, deletes, blocks, statistics. +# A delivery is never checked with ap/show, which would fetch it from us; only with what Misskey already holds. +MK_NAME=${MISSKEY_NAME:-misskey} +MKH="$MK_NAME.test" +MK="https://$MKH:6443" +MKT=$(cat "$here/.state/$MK_NAME.token" 2>/dev/null) +# mk [json]: a Misskey API call as mkuser +mk() { + local body=${2:-'{}'} + site "$MKH" -s -X POST "$MK/api/$1" -H 'Content-Type: application/json' \ + -d "$(python3 -c "import sys,json; d=json.loads(sys.argv[1]); d['i']=sys.argv[2]; print(json.dumps(d))" "$body" "$MKT")" +} +# the notes Misskey holds of an account, and the one whose uri is given +mk_notes() { mk users/notes "{\"userId\":\"$1\",\"limit\":50,\"withReplies\":true,\"withRenotes\":true}"; } +# users/relation answers a list even for one user +mk_relation() { mk users/relation "{\"userId\":\"$1\"}" | j "r=d[0] if isinstance(d,list) else d; print(r['$2'])"; } +mk_note_by_uri() { mk_notes "$1" | j "print(json.dumps(next((n for n in d if n.get('uri')=='$2'), None)))"; } + +echo "$MK_NAME" +[ -n "$MKT" ] && ok "$MK_NAME token for mkuser" || { ko "$MK_NAME token"; return 1; } +KT=$(privapub_token "alice_$MK_NAME") +KH="Authorization: Bearer $KT" +[ -n "$KT" ] && ok "PrivaPub token for alice_$MK_NAME" || { ko "PrivaPub token for alice_$MK_NAME"; return 1; } + +echo " discovery" +alice_on_mk=$(mk users/show "{\"username\":\"alice_$MK_NAME\",\"host\":\"privapub.test\"}" | j "print(d['id'])") +[ -n "$alice_on_mk" ] && ok "$MK_NAME resolves @alice_$MK_NAME@privapub.test" || ko "$MK_NAME cannot resolve alice" +mk_on_p=$(curl -s -H "$KH" "$P/api/v2/search?q=mkuser@$MKH&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$mk_on_p" ] && ok "PrivaPub resolves @mkuser@$MKH" || ko "PrivaPub cannot resolve mkuser" + +echo " follows" +mk following/create "{\"userId\":\"$alice_on_mk\"}" >/dev/null +until_true 30 '[ "$(mk_relation "$alice_on_mk" isFollowing)" = "True" ]' && ok "mkuser follows alice (Accept arrived)" || ko "$MK_NAME's follow not accepted" +curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/follow" +until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/accounts/relationships?id[]=$mk_on_p" | j "print(d[0][\"following\"])")" = "True" ]' && ok "alice follows mkuser (Accept arrived)" || ko "PrivaPub's follow not accepted" + +echo " posts" +k_post=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=Hello $MK_NAME from PrivaPub&visibility=public") +k_post_id=$(echo "$k_post" | j "print(d['id'])"); k_post_uri=$(echo "$k_post" | j "print(d['uri'])") +until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_post_uri")" != "null" ]' && ok "alice's post reaches $MK_NAME" || ko "post missing on $MK_NAME" +k_post_on_mk=$(mk_note_by_uri "$alice_on_mk" "$k_post_uri" | j "print(d['id'])") +mk_post=$(mk notes/create "{\"text\":\"Hello PrivaPub from $MK_NAME, \$[x2 big] words\",\"visibility\":\"public\"}" | j "print(d['createdNote']['id'])") +until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "Hello PrivaPub from $MK_NAME"' && ok "mkuser's post reaches alice's home" || ko "$MK_NAME's post missing on PrivaPub" +mk_post_on_p=$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'Hello PrivaPub from $MK_NAME' in s['content']))") +[ "$(curl -s -H "$KH" "$P/api/v1/statuses/$mk_post_on_p" | j "print('\$[x2' in ((d.get('privapub') or {}).get('source') or {}).get('content',''))")" = "True" ] \ + && ok "the MFM source is kept" || ko "MFM source lost" +cw_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d 'status=behind a warning&spoiler_text=spoilers&visibility=public' | j "print(d['uri'])") +until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$cw_uri" | j "print(d and d[\"cw\"])")" = "spoilers" ]' && ok "a content warning survives to $MK_NAME" || ko "content warning lost on $MK_NAME" + +echo " replies" +mk notes/create "{\"text\":\"@alice_$MK_NAME@privapub.test replying from $MK_NAME\",\"replyId\":\"$k_post_on_mk\",\"visibility\":\"public\"}" >/dev/null +until_true 30 'curl -s -H "$KH" "$P/api/v1/notifications" | j "print(any(n[\"type\"]==\"mention\" for n in d))" | grep -q True' && ok "mkuser's reply notifies alice" || ko "reply did not notify" +until_true 15 '[ "$(curl -s -H "$KH" "$P/api/v1/statuses/$k_post_id/context" | j "print(len(d[\"descendants\"]))")" -ge 1 ]' && ok "the reply threads under alice's post" || ko "reply not threaded on PrivaPub" +k_reply_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=@mkuser@$MKH replying from PrivaPub&in_reply_to_id=$mk_post_on_p&visibility=public" | j "print(d['uri'])") +until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_reply_uri" | j "print(d and d[\"replyId\"])")" = "$mk_post" ]' && ok "alice's reply threads under mkuser's note" || ko "outbound reply not threaded on $MK_NAME" + +echo " reactions" +mk notes/reactions/create "{\"noteId\":\"$k_post_on_mk\",\"reaction\":\"👍\"}" >/dev/null +until_true 30 'curl -s -H "$KH" "$P/api/v1/pleroma/statuses/$k_post_id/reactions" | j "print(any(r[\"name\"]==\"👍\" and r[\"count\"]==1 for r in d))" | grep -q True' && ok "mkuser's 👍 reaction reaches PrivaPub" || ko "reaction missing on PrivaPub" +mk notes/reactions/delete "{\"noteId\":\"$k_post_on_mk\"}" >/dev/null +until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/pleroma/statuses/$k_post_id/reactions" | j "print(len(d))")" = "0" ]' && ok "mkuser's withdrawn reaction leaves PrivaPub" || ko "reaction not withdrawn on PrivaPub" +curl -s -o /dev/null -X PUT -H "$KH" "$P/api/v1/pleroma/statuses/$mk_post_on_p/reactions/%F0%9F%8E%89" +until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$mk_post\"}" | j "print(d[\"reactions\"].get(\"🎉\", 0))")" = "1" ]' && ok "alice's 🎉 reaction reaches $MK_NAME" || ko "reaction missing on $MK_NAME" +# Misskey keeps one reaction per user and note, so the like goes to a note of its own +mk_liked=$(mk notes/create "{\"text\":\"like this one, PrivaPub\",\"visibility\":\"public\"}" | j "print(d['createdNote']['id'])") +until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "like this one, PrivaPub"' || true +mk_liked_on_p=$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'like this one, PrivaPub' in s['content']))") +curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/statuses/$mk_liked_on_p/favourite" +until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$mk_liked\"}" | j "print(sum(d[\"reactions\"].values()))")" = "1" ]' && ok "alice's like counts as a reaction on $MK_NAME" || ko "like not counted on $MK_NAME" + +echo " quotes" +mk_quote=$(mk notes/create "{\"text\":\"quoting PrivaPub\",\"renoteId\":\"$k_post_on_mk\",\"visibility\":\"public\"}" | j "print(d['createdNote']['id'])") +until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(\"quoting PrivaPub\" in s[\"content\"] and (s.get(\"quote\") or {}).get(\"state\")==\"accepted\" for s in d))" | grep -q True' \ + && ok "mkuser's quote arrives as an accepted quote" || ko "$MK_NAME's quote not understood" +k_quote_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=quoting $MK_NAME&visibility=public"ed_status_id=$mk_post_on_p" | j "print(d['uri'])") +until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_quote_uri" | j "print(d and d[\"renoteId\"])")" = "$mk_post" ]' && ok "alice's quote is a quote on $MK_NAME" || ko "quote not understood by $MK_NAME" + +echo " polls" +k_poll_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d 'status=cats or dogs&visibility=public&poll[options][]=cats&poll[options][]=dogs&poll[expires_in]=3600' | j "print(d['uri'])") +until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_poll_uri" | j "print(len(d[\"poll\"][\"choices\"]))")" = "2" ]' && ok "alice's poll reaches $MK_NAME as a poll" || ko "poll missing on $MK_NAME" +k_poll_on_mk=$(mk_note_by_uri "$alice_on_mk" "$k_poll_uri" | j "print(d['id'])") +mk notes/polls/vote "{\"noteId\":\"$k_poll_on_mk\",\"choice\":1}" >/dev/null +alice_self=$(curl -s -H "$KH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])") +until_true 30 '[ "$(curl -s -H "$KH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s[\"poll\"][\"options\"][1][\"votes_count\"] for s in d if s[\"uri\"]==\"$k_poll_uri\"))")" = "1" ]' && ok "mkuser's vote counts on PrivaPub" || ko "vote not counted on PrivaPub" +mk_poll=$(mk notes/create "{\"text\":\"tea or coffee, $MK_NAME asks\",\"visibility\":\"public\",\"poll\":{\"choices\":[\"tea\",\"coffee\"],\"expiredAfter\":3600000}}" | j "print(d['createdNote']['id'])") +until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(s[\"poll\"] and \"$MK_NAME asks\" in s[\"content\"] for s in d))" | grep -q True' && ok "mkuser's poll reaches PrivaPub as a poll" || ko "poll missing on PrivaPub" +mk_poll_on_p=$(curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(next(s['poll']['id'] for s in d if s['poll'] and '$MK_NAME asks' in s['content']))") +curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/polls/$mk_poll_on_p/votes" -d 'choices[]=0' +until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$mk_poll\"}" | j "print(d[\"poll\"][\"choices\"][0][\"votes\"])")" = "1" ]' && ok "alice's vote counts on $MK_NAME" || ko "vote not counted on $MK_NAME" + +echo " specified notes" +mk notes/create "{\"text\":\"@alice_$MK_NAME@privapub.test a secret for PrivaPub\",\"visibility\":\"specified\",\"visibleUserIds\":[\"$alice_on_mk\"]}" >/dev/null +until_true 30 'curl -s -H "$KH" "$P/api/v1/conversations" | grep -q "a secret for PrivaPub"' && ok "mkuser's specified note arrives as a DM" || ko "specified note missing on PrivaPub" +curl -s -o /dev/null -X POST -H "$KH" $P/api/v1/statuses -d "status=@mkuser@$MKH a secret for $MK_NAME&visibility=direct" +until_true 30 'mk notes/mentions "{\"visibility\":\"specified\"}" | grep -q "a secret for $MK_NAME"' && ok "alice's DM arrives as a specified note" || ko "DM missing on $MK_NAME" + +echo " media" +make_png "$work/red.png" +k_media=$(curl -s -X POST -H "$KH" "$P/api/v2/media" -F "file=@$work/red.png;type=image/png" -F 'description=a red square' | j "print(d['id'])") +k_media_uri=$(curl -s -X POST -H "$KH" $P/api/v1/statuses -d "status=a picture for $MK_NAME&visibility=public&media_ids[]=$k_media" | j "print(d['uri'])") +until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$k_media_uri" | j "print(d[\"files\"][0][\"comment\"])")" = "a red square" ]' && ok "an image with alt text reaches $MK_NAME" || ko "image or alt text missing on $MK_NAME" +mk_file=$(site "$MKH" -s -X POST "$MK/api/drive/files/create" -F "i=$MKT" -F "file=@$work/red.png;type=image/png" -F "comment=a red square from $MK_NAME" | j "print(d['id'])") +mk notes/create "{\"text\":\"a picture from $MK_NAME\",\"visibility\":\"public\",\"fileIds\":[\"$mk_file\"]}" >/dev/null +until_true 30 'curl -s -H "$KH" "$P/api/v1/timelines/home" | j "print(any(\"a picture from $MK_NAME\" in s[\"content\"] and s[\"media_attachments\"] and \"/media/proxy/\" in s[\"media_attachments\"][0][\"url\"] and s[\"media_attachments\"][0][\"description\"]==\"a red square from $MK_NAME\" for s in d))" | grep -q True' \ + && ok "an image with alt text from $MK_NAME arrives through our proxy" || ko "$MK_NAME's image missing, unproxied or without alt text" + +echo " deletes" +cw_on_mk=$(mk_note_by_uri "$alice_on_mk" "$cw_uri" | j "print(d['id'])") +cw_id=$(curl -s -H "$KH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s['id'] for s in d if s['uri']=='$cw_uri'))") +curl -s -o /dev/null -X DELETE -H "$KH" "$P/api/v1/statuses/$cw_id" +until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$cw_on_mk\"}" | j "print(d.get(\"error\",{}).get(\"code\"))")" = "NO_SUCH_NOTE" ]' && ok "alice's delete reaches $MK_NAME" || ko "delete not applied on $MK_NAME" +mk notes/delete "{\"noteId\":\"$mk_poll\"}" >/dev/null +until_true 30 '! curl -s -H "$KH" "$P/api/v1/timelines/home" | grep -q "$MK_NAME asks"' && ok "mkuser's delete reaches PrivaPub" || ko "$MK_NAME's delete not applied on PrivaPub" + +echo " unfollows and blocks" +curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/unfollow" +until_true 30 '[ "$(mk_relation "$alice_on_mk" isFollowed)" = "False" ]' && ok "alice's unfollow reaches $MK_NAME" || ko "unfollow not applied on $MK_NAME" +curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/block" +until_true 30 '[ "$(mk_relation "$alice_on_mk" isBlocked)" = "True" ]' && ok "alice's block reaches $MK_NAME" || ko "block not applied on $MK_NAME" +curl -s -o /dev/null -X POST -H "$KH" "$P/api/v1/accounts/$mk_on_p/unblock" + +echo " statistics" +stats_check "$MKH" "$MK_NAME"