Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community, and it answered PrivaPub's Flag (the instance actor's, an Application, with no `to` and the account and posts as its object) 400. A report of a post or comment in a community on a server whose NodeInfo names Lemmy now leaves from `privapub_reports`, a Service with its own key that names nobody: one Flag per post, `to` the community (its own audience, else its thread's), with the persona's words, or the category, in `summary` and `content`, sent to the community's inbox. This is the second exception to "a server's software is for display" (owner decision 2026-10-06, `ReportService.ServiceReportTakers`). Every other server keeps the instance actor's report. An account alone is not reported to Lemmy, which takes no such report, and `forwarded` now says whether anything left. The reporter is read unsigned in SecureMode and answers WebFinger like the instance actor. Nobody follows or mentions it, the Mastodon API has no account for it, and a migration reserves its name. Checked live: Lemmy 1.0 and 0.19 keep the reports of a thread and of a comment, with alice's words, from "Reports from privapub.test", and none names her (69 checks). A sweep of every scenario with this and the next commit: 876 checks pass; Ghost's Network feed listed alice's post too late once, and Ghost passes alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
139 lines
14 KiB
Bash
139 lines
14 KiB
Bash
# Lemmy 1.0: communities both ways (FEP-1b12), threads with titles, comments both ways, votes up and down both ways,
|
|
# private messages both ways, a moderator's lock, ban and removal, statistics. Lemmy is driven through its v4 API.
|
|
LM=https://lemmy.test:6443
|
|
LT=$(cat "$here/.state/lemmy.token" 2>/dev/null)
|
|
# lm <method> <path> [json]: a Lemmy v4 API call as lemmyuser
|
|
lm() {
|
|
local method=$1 path=$2 body=${3:-}
|
|
if [ -n "$body" ]; then
|
|
site lemmy.test -s -X "$method" "$LM/api/v4/$path" -H "Authorization: Bearer $LT" -H 'Content-Type: application/json' -d "$body"
|
|
else
|
|
site lemmy.test -s -X "$method" "$LM/api/v4/$path" -H "Authorization: Bearer $LT"
|
|
fi
|
|
}
|
|
# the posts Lemmy holds in a community, and the one whose ap_id is given
|
|
lm_posts() { lm GET "post/list?community_id=$1&sort=new&limit=50" | j "print(json.dumps([p['post'] for p in (d.get('items') or d.get('posts') or [])]))"; }
|
|
# 1 once Lemmy runs a send worker for privapub.test
|
|
lm_worker() { podman exec pasture-postgres psql -U pasture -d lemmy -tAc "select count(*) from federation_queue_state q join instance i on i.id=q.instance_id where i.domain='privapub.test'"; }
|
|
lm_post_by_ap() { lm_posts "$1" | j "print(json.dumps(next((p for p in d if p['ap_id']=='$2'), None)))"; }
|
|
|
|
echo "lemmy"
|
|
[ -n "$LT" ] && ok "Lemmy token for lemmyuser" || { ko "Lemmy token"; return 1; }
|
|
LAT=$(privapub_token alice_lemmy)
|
|
LAH="Authorization: Bearer $LAT"
|
|
[ -n "$LAT" ] && ok "PrivaPub token for alice_lemmy" || { ko "PrivaPub token for alice_lemmy"; return 1; }
|
|
jwt=$(privapub_root)
|
|
alice_id=$(curl -s -H "$LAH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
|
|
dogs="dogs$(date +%s)"
|
|
cats="cats$(date +%s)"
|
|
|
|
echo " communities"
|
|
dogs_gid=$(curl -s -X POST $P/clientapi/group/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
|
|
-d "{\"avatarId\":\"$alice_id\",\"userName\":\"$dogs\",\"name\":\"Pasture dogs\",\"description\":\"a PrivaPub community\",\"isCommunity\":true}" | j "print(d['id'])")
|
|
dogs_on_lm=$(lm GET "resolve_object?q=!$dogs@privapub.test" | j "print(d['community']['id'])")
|
|
[ -n "$dogs_on_lm" ] && ok "Lemmy resolves a PrivaPub community" || ko "Lemmy cannot resolve the PrivaPub community"
|
|
# Lemmy 1.0 starts its send worker for a newly seen server at the newest activity and never sends anything queued for
|
|
# that server before then, so on a clean pasture the first follow waits for the worker (up to a minute)
|
|
until_true 45 '[ "$(lm_worker)" = "1" ]' || true
|
|
lm POST community/follow "{\"community_id\":$dogs_on_lm,\"follow\":true}" >/dev/null
|
|
until_true 30 '[ "$(lm GET "community?id=$dogs_on_lm" | j "print(((d[\"community_view\"].get(\"community_actions\") or {}).get(\"follow_state\") or \"\").lower())")" = "accepted" ]' \
|
|
&& ok "lemmyuser follows the PrivaPub community (Accept arrived)" || ko "Lemmy's community follow not accepted"
|
|
cats_on_lm=$(lm POST community "{\"name\":\"$cats\",\"title\":\"Pasture cats\"}" | j "print(d['community_view']['community']['id'])")
|
|
cats_on_p=$(curl -s -H "$LAH" "$P/api/v2/search?q=@$cats@lemmy.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
|
[ -n "$cats_on_p" ] && ok "PrivaPub resolves a Lemmy community" || ko "PrivaPub cannot resolve the Lemmy community"
|
|
curl -s -o /dev/null -X POST -H "$LAH" "$P/api/v1/accounts/$cats_on_p/follow"
|
|
until_true 30 '[ "$(curl -s -H "$LAH" "$P/api/v1/accounts/relationships?id[]=$cats_on_p" | j "print(d[0][\"following\"])")" = "True" ]' && ok "alice follows the Lemmy community (Accept arrived)" || ko "PrivaPub's community follow not accepted"
|
|
|
|
echo " threads"
|
|
lm_thread=$(lm POST post "{\"name\":\"A Lemmy thread\",\"body\":\"posted from Lemmy into PrivaPub\",\"community_id\":$dogs_on_lm}" | j "print(d['post_view']['post']['ap_id'])")
|
|
dogs_on_p=$(curl -s -H "$LAH" "$P/api/v1/accounts/lookup?acct=$dogs" | j "print(d['id'])")
|
|
until_true 30 '[ "$(curl -s -H "$LAH" "$P/api/v1/accounts/$dogs_on_p/statuses" | j "print(any(s[\"uri\"]==\"$lm_thread\" and (s.get(\"privapub\") or {}).get(\"title\")==\"A Lemmy thread\" for s in d))")" = "True" ]' \
|
|
&& ok "a Lemmy thread in the PrivaPub community arrives with its title" || ko "Lemmy's thread missing or untitled on PrivaPub"
|
|
curl -s -o /dev/null -X POST $P/clientapi/post/insert -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
|
|
-d "{\"avatarId\":\"$alice_id\",\"title\":\"A PrivaPub community thread\",\"text\":\"posted into our own community\",\"groupId\":\"$dogs_gid\"}"
|
|
dogs_post=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Post.findOne({Text:/posted into our own community/}, {}, {sort:{_id:-1}}).ObjectURI)')
|
|
until_true 30 '[ "$(lm_post_by_ap "$dogs_on_lm" "$dogs_post" | j "print(d and d[\"name\"])")" = "A PrivaPub community thread" ]' \
|
|
&& ok "a titled post in the PrivaPub community reaches Lemmy through its announce" || ko "PrivaPub community post missing on Lemmy"
|
|
# Lemmy 1.0 holds its user's thread in a remote community as pending until the community announces it back, and clears
|
|
# the flag even though it then answers that Announce 400 ("Object is not remote"): the echo is what publishes it.
|
|
until_true 30 '[ "$(lm_post_by_ap "$dogs_on_lm" "$lm_thread" | j "print(d and d[\"federation_pending\"])")" = "False" ]' \
|
|
&& ok "the PrivaPub community's announce back publishes Lemmy's thread there" || ko "Lemmy's thread stays federation_pending"
|
|
lm_cats_thread=$(lm POST post "{\"name\":\"Cats only\",\"body\":\"a Lemmy community post\",\"community_id\":$cats_on_lm}" | j "print(d['post_view']['post']['ap_id'])")
|
|
until_true 30 'curl -s -H "$LAH" "$P/api/v1/timelines/home" | j "print(any(s[\"uri\"]==\"$lm_cats_thread\" or (s.get(\"reblog\") or {}).get(\"uri\")==\"$lm_cats_thread\" for s in d))" | grep -q True' \
|
|
&& ok "the Lemmy community's announce brings its thread to alice's home" || ko "Lemmy community thread missing from alice's home"
|
|
p_thread=$(curl -s -X POST -H "$LAH" $P/api/v1/statuses --data-urlencode "status=A PrivaPub thread
|
|
@$cats@lemmy.test posted from PrivaPub" -d 'visibility=public')
|
|
p_thread_id=$(echo "$p_thread" | j "print(d['id'])"); p_thread_uri=$(echo "$p_thread" | j "print(d['uri'])")
|
|
until_true 30 '[ "$(lm_post_by_ap "$cats_on_lm" "$p_thread_uri")" != "null" ]' && ok "alice's thread lands in the Lemmy community" || ko "PrivaPub thread missing from the Lemmy community"
|
|
p_thread_on_lm=$(lm_post_by_ap "$cats_on_lm" "$p_thread_uri" | j "print(d['id'])")
|
|
|
|
echo " comments"
|
|
lm POST comment "{\"post_id\":$p_thread_on_lm,\"content\":\"a Lemmy comment on PrivaPub\"}" >/dev/null
|
|
until_true 30 '[ "$(curl -s -H "$LAH" "$P/api/v1/statuses/$p_thread_id/context" | j "print(any(\"a Lemmy comment on PrivaPub\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \
|
|
&& ok "Lemmy's comment threads under alice's post" || ko "Lemmy's comment missing on PrivaPub"
|
|
lm_cats_on_p=$(curl -s -H "$LAH" "$P/api/v1/timelines/home" | j "print(next((s.get('reblog') or s)['id'] for s in d if (s.get('reblog') or s)['uri']=='$lm_cats_thread'))")
|
|
curl -s -o /dev/null -X POST -H "$LAH" $P/api/v1/statuses -d "status=a PrivaPub comment on Lemmy&in_reply_to_id=$lm_cats_on_p&visibility=public"
|
|
lm_cats_id=$(lm GET "resolve_object?q=$lm_cats_thread" | j "print(d['post']['id'])")
|
|
until_true 30 '[ "$(lm GET "comment/list?post_id=$lm_cats_id" | j "print(any(\"a PrivaPub comment on Lemmy\" in c[\"comment\"][\"content\"] for c in (d.get(\"items\") or d.get(\"comments\") or [])))")" = "True" ]' \
|
|
&& ok "alice's reply becomes a comment on Lemmy" || ko "PrivaPub reply missing as a Lemmy comment"
|
|
|
|
echo " votes"
|
|
# Lemmy sends a vote only to the community, which relays it inside an Announce (trusted since ed08f80); Lemmy 1.0 sends
|
|
# what it queued every 30 seconds, so a vote takes up to a minute
|
|
lm POST post/like "{\"post_id\":$p_thread_on_lm,\"is_upvote\":true}" >/dev/null
|
|
until_true 45 '[ "$(curl -s -H "$LAH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"])")" = "1" ]' \
|
|
&& ok "Lemmy's upvote counts as a like on PrivaPub" || ko "Lemmy's relayed upvote not counted"
|
|
lm POST post/like "{\"post_id\":$p_thread_on_lm,\"is_upvote\":false}" >/dev/null
|
|
until_true 45 '[ "$(curl -s -H "$LAH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"], ((d.get(\"privapub\") or {}).get(\"votes\") or {}).get(\"down\"))")" = "0 1" ]' \
|
|
&& ok "Lemmy's change to a downvote reaches PrivaPub" || ko "Lemmy's change to a downvote not applied"
|
|
curl -s -o /dev/null -X POST -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p/favourite"
|
|
until_true 30 '[ "$(lm GET "post?id=$lm_cats_id" | j "print(d[\"post_view\"][\"post\"].get(\"upvotes\"))")" = "2" ]' && ok "alice's like is an upvote on Lemmy" || ko "like not counted as an upvote on Lemmy"
|
|
|
|
echo " private messages"
|
|
alice_on_lm=$(lm GET "resolve_object?q=@alice_lemmy@privapub.test" | j "print(d['person']['id'])")
|
|
lm POST private_message "{\"content\":\"a secret from Lemmy\",\"recipient_id\":$alice_on_lm}" >/dev/null
|
|
until_true 30 'curl -s -H "$LAH" "$P/api/v1/conversations" | grep -q "a secret from Lemmy"' && ok "Lemmy's private message arrives as a DM" || ko "Lemmy's private message missing on PrivaPub"
|
|
curl -s -o /dev/null -X POST -H "$LAH" $P/api/v1/statuses -d 'status=@lemmyuser@lemmy.test a secret from PrivaPub&visibility=direct'
|
|
until_true 30 'lm GET "account/notification/list?limit=50" | grep -q "a secret from PrivaPub"' && ok "alice's DM arrives as a Lemmy private message" || ko "DM missing on Lemmy"
|
|
|
|
echo " moderation"
|
|
# a lock and a ban, relayed by the community inside its Announce (G-0006); Lemmy sends what it queued every 30 seconds
|
|
# (Lemmy 1.0 refuses a lock, an unlock or an unban without a reason, and then federates nothing)
|
|
p_locked() { curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p" | j "print((d.get('privapub') or {}).get('locked'))"; }
|
|
p_reply() { curl -s -o /dev/null -w '%{http_code}' -X POST -H "$LAH" $P/api/v1/statuses -d "status=$1&in_reply_to_id=$lm_cats_on_p&visibility=public"; }
|
|
p_banned() { curl -s -H "$LAH" "$P/api/v1/accounts/relationships?id[]=$cats_on_p" | j "print(d[0]['blocked_by'])"; }
|
|
lm POST post/lock "{\"post_id\":$lm_cats_id,\"locked\":true,\"reason\":\"pasture\"}" >/dev/null
|
|
until_true 45 '[ "$(p_locked)" = "True" ]' && ok "a moderator's lock reaches PrivaPub" || ko "a moderator's lock did not reach PrivaPub"
|
|
[ "$(p_reply "too late")" = "422" ] && ok "a reply to the locked thread is refused" || ko "a reply to the locked thread was taken"
|
|
lm POST post/lock "{\"post_id\":$lm_cats_id,\"locked\":false,\"reason\":\"pasture\"}" >/dev/null
|
|
until_true 45 '[ "$(p_locked)" = "False" ]' && ok "a moderator's unlock reaches PrivaPub" || ko "a moderator's unlock did not reach PrivaPub"
|
|
lm POST community/ban_user "{\"community_id\":$cats_on_lm,\"person_id\":$alice_on_lm,\"ban\":true,\"reason\":\"pasture\"}" >/dev/null
|
|
until_true 45 '[ "$(p_banned)" = "True" ]' && ok "the community's ban of alice_lemmy reaches PrivaPub (blocked_by)" || ko "the community's ban did not reach PrivaPub"
|
|
[ "$(p_reply "while banned")" = "422" ] && ok "a banned persona's reply in the community is refused" || ko "a banned persona's reply was taken"
|
|
lm POST community/ban_user "{\"community_id\":$cats_on_lm,\"person_id\":$alice_on_lm,\"ban\":false,\"reason\":\"pasture\"}" >/dev/null
|
|
until_true 45 '[ "$(p_banned)" = "False" ]' && ok "the community's unban reaches PrivaPub" || ko "the community's unban did not reach PrivaPub"
|
|
echo " reports"
|
|
# Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community (owner
|
|
# decision 2026-10-06): alice's report of lemmyuser's thread and comment leaves from PrivaPub's reporter, one Flag each,
|
|
# and never names her. Lemmy sends nothing back; what it keeps is read from its database.
|
|
lm POST comment "{\"post_id\":$lm_cats_id,\"content\":\"a Lemmy comment to report\"}" >/dev/null
|
|
lm_comment_on_p() { curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p/context" | j "print(next((s['id'] for s in d['descendants'] if 'a Lemmy comment to report' in s['content']), ''))"; }
|
|
until_true 45 '[ -n "$(lm_comment_on_p)" ]' && ok "lemmyuser's comment to report reaches PrivaPub" || ko "lemmyuser's comment never reached PrivaPub"
|
|
lm_author_on_p=$(curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p" | j "print(d['account']['id'])")
|
|
reason="a pasture report $(date +%s)"
|
|
[ "$(p_report "$LAH" "$lm_author_on_p" "$reason" "$lm_cats_on_p" "$(lm_comment_on_p)")" = "True" ] \
|
|
&& ok "PrivaPub says alice's report was forwarded" || ko "PrivaPub did not forward alice's report"
|
|
lm_reports() { podman exec pasture-postgres psql -U pasture -d lemmy -tAc "select count(*) from $1 r join person p on p.id = r.creator_id where r.reason = '$reason' and p.ap_id = '$P_REPORTER'"; }
|
|
until_true 45 '[ "$(lm_reports post_report)" = "1" ]' && ok "Lemmy keeps the thread's report from PrivaPub's reporter, with alice's words" || ko "Lemmy kept no report of the thread"
|
|
until_true 45 '[ "$(lm_reports comment_report)" = "1" ]' && ok "Lemmy keeps the comment's report from PrivaPub's reporter, with alice's words" || ko "Lemmy kept no report of the comment"
|
|
[ "$(podman exec pasture-postgres psql -U pasture -d lemmy -tAc "select count(*) from post_report r join person p on p.id = r.creator_id where p.ap_id like '%alice_lemmy%'")" = "0" ] \
|
|
&& ok "no report on Lemmy names alice" || ko "a report on Lemmy names alice"
|
|
# the community relays it as Announce{Delete}, believed once Lemmy answers 410 for the post; Lemmy sends what it queued
|
|
# every 30 seconds
|
|
lm POST post/remove "{\"post_id\":$lm_cats_id,\"removed\":true,\"reason\":\"pasture\"}" >/dev/null
|
|
until_true 45 'curl -s -H "$LAH" "$P/api/v1/timelines/home" | j "print(any((s.get(\"reblog\") or s)[\"uri\"]==\"$lm_cats_thread\" for s in d))" | grep -q False' \
|
|
&& ok "a moderator's removal reaches PrivaPub" || ko "a moderator's removal did not reach PrivaPub"
|
|
|
|
echo " statistics"
|
|
stats_check lemmy.test lemmy
|