R6 moderation: roles, silence and suspension, bans that write nothing on the personas, deletions, report actions

Roles (user, moderator, administrator) from the CLI and /clientapi/admin/roots/{id}/role, never one's own nor the last
administrator. Silence keeps an account's posts out of public timelines, hashtags, trends, search and relays while its
followers still get them; suspension refuses and revokes a local persona's tokens, answers 404 for its posts and
collections, holds back every delivery and tells other servers nothing new, its actor unchanged; a suspended remote
account's activities are dropped and nothing goes to its own inbox (a shared one still serves its server). A banned
login's personas are held back in memory alone (Restrictions), so nothing pairs them in stored data. Personas and groups
are deleted one at a time (PersonaRemoval, GroupRemoval), by their login or an administrator with the password. Reports
resolve with an action and a note; posts are taken down, a remote copy hidden. Warnings reach the persona as Mastodon's
moderation_warning. Every staff act goes to the staff's log. Migration _020 limits posts from servers already silenced;
a restore keeps the moderation made since the backup.

Verified: interop.sh moderation gts mastodon with SecureMode, 194 passed; restore.sh, 25 passed, now with a suspension,
a silence and a takedown made after the backup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-17 05:23:45 +02:00
1 parent 70d1ffe9a2
commit 9f68ca3ed0
71 files changed
+2229 -219

No files matched your search

+268
View File
@@ -0,0 +1,268 @@
# Moderation (completion program R6), with GoToSocial and Mastodon. A second login is made a moderator through the
# administrator's route (its own role refused, a moderator refused the logins); a silenced persona's post stays out of the
# public and hashtag timelines and still reaches its followers, here and on Mastodon; a suspended persona's token answers
# 401 and its account, posts and collections 404, while its actor is still served and Mastodon is told nothing; a
# suspended GoToSocial account's new activity is dropped, nothing goes to it, and unsuspending shows its earlier post
# again; a silenced server's posts leave the federated timeline; a banned login's personas answer nothing (a Mastodon
# follow stays requested) and carry no moderation of their own; a persona deleted by an administrator is gone on Mastodon
# and GoToSocial, another deleted by its own login, groups deleted by their owner and by an administrator; posts removed
# directly and through a report are gone on Mastodon; the staff's log lists every act, without an address. Needs the gts
# and mastodon peers; run it after instance (batch A), whose log check expects only the administrator's acts.
G=https://gts.test:6443
gcurl() { curl -sk --resolve gts.test:6443:127.0.0.1 "$@"; }
M=https://mastodon.test:6443
mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; }
. "$here/peers/mastodon.sh"
api() { local jwt=$1 method=$2 path=$3; shift 3; curl -s -X "$method" -H "Authorization: Bearer $jwt" -H 'Content-Type: application/json' "$P$path" "$@"; }
code_of() { local jwt=$1 method=$2 path=$3; shift 3; curl -s -o /dev/null -w '%{http_code}' -X "$method" -H "Authorization: Bearer $jwt" -H 'Content-Type: application/json' "$P$path" "$@"; }
login() { curl -s -X POST "$P/clientapi/user/login" -H 'Content-Type: application/json' -d "{\"userName\":\"$1\",\"password\":\"$2\"}" | j "print(d['token'])"; }
code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
# m_sql <query>: one value from Mastodon's database (its API caches relationships for a day)
m_sql() { podman exec pasture-postgres psql -U pasture -d mastodon -Atc "$1" 2>/dev/null; }
# signed_status <url>: what a GET of one of our documents signed by Mastodon's instance actor gets (SecureMode answers an
# unsigned one 401 whatever it asks for)
signed_status() { podman exec pasture-mastodon bin/rails runner "Request.new(:get, '$1').on_behalf_of(Account.representative).add_headers('Accept' => 'application/activity+json').perform { |r| puts r.code }" 2>/dev/null | tail -1; }
p_mongo() { podman exec pasture-mongo mongosh --quiet PrivaPub --eval "$1" 2>/dev/null | tail -1; }
# login_token <login> <password> <persona>: the persona, made under that login if needed, and a Mastodon token for it
login_token() {
local jwt
jwt=$(login "$1" "$2")
curl -s -o /dev/null -X POST "$P/clientapi/avatar/private/insert" -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"userName\":\"$3\",\"name\":\"$3 of PrivaPub\",\"biography\":\"moderated in the pasture\"}"
"$here/../smoke/oauth.sh" "$P" "$1" "$2" "$3" "read write follow" 2>/dev/null | cut -d' ' -f1
}
me_id() { curl -s -H "Authorization: Bearer $1" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])"; }
post_public() { curl -s -X POST -H "Authorization: Bearer $1" "$P/api/v1/statuses" --data-urlencode "status=$2" -d visibility=public; }
in_public() { curl -s "$P/api/v1/timelines/public?limit=40${2:-}" | j "print('$1' in [s['id'] for s in d])"; }
in_tag() { curl -s "$P/api/v1/timelines/tag/$2?limit=40" | j "print('$1' in [s['id'] for s in d])"; }
in_home() { curl -s -H "Authorization: Bearer $1" "$P/api/v1/timelines/home?limit=40" | j "print('$2' in [s['id'] for s in d])"; }
warned() { curl -s -H "Authorization: Bearer $1" "$P/api/v1/notifications?types[]=moderation_warning" | j "print(any(n['type'] == 'moderation_warning' and n['moderation_warning']['action'] == '$2' for n in d))"; }
moderate() { api "$MOD" POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$1\",\"action\":\"$2\",\"note\":\"pasture $2\",\"warning\":\"${3:-}\"}"; }
m_resolve() { mcurl -H "$MH" "$M/api/v2/search?q=@$1@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])"; }
m_follow() { mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/accounts/$1/follow"; }
m_holds() { m_sql "select count(*) from statuses where uri = '$1' and deleted_at is null"; }
gts_token() {
local app id secret code jar
jar=$(mktemp); app=$(gcurl -X POST $G/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read+write+follow')
id=$(echo "$app" | j "print(d['client_id'])"); secret=$(echo "$app" | j "print(d['client_secret'])")
gcurl -o /dev/null -c "$jar" -b "$jar" "$G/oauth/authorize?client_id=$id&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=read+write+follow"
gcurl -o /dev/null -c "$jar" -b "$jar" -X POST $G/auth/sign_in --data-urlencode 'username=gtsuser@gts.test' --data-urlencode 'password=Gts-Pasture-Pass-1!'
code=$(gcurl -o /dev/null -w '%{redirect_url}' -c "$jar" -b "$jar" -X POST "$G/oauth/authorize" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
rm -f "$jar"
gcurl -X POST $G/oauth/token -d "grant_type=authorization_code&code=$code&client_id=$id&client_secret=$secret&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=read+write+follow" | j "print(d['access_token'])"
}
g_resolve() { gcurl -H "$GH" "$G/api/v2/search?q=@$1@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])"; }
echo "moderation"
run=$(date +%s)
STAFF_PASS='Moderation-Pass-1!'
privapub_root >/dev/null
podman exec -w /app pasture-privapub /app/PrivaPub admin promote "$ROOT_USER" >/dev/null 2>&1 || true
ADMIN=$(login "$ROOT_USER" "$ROOT_PASS")
[ -n "$ADMIN" ] && ok "the pasture's root signs in as the administrator" || { ko "no administrator's login"; return 1; }
MT=$(mastodon_token); MH="Authorization: Bearer $MT"
GT=$(gts_token); GH="Authorization: Bearer $GT"
[ -n "$MT" ] && [ -n "$GT" ] && ok "tokens for mastouser and gtsuser" || { ko "no peer tokens"; return 1; }
m_me=$(mcurl -H "$MH" "$M/api/v1/accounts/verify_credentials" | j "print(d['id'])")
echo " roles"
echo "$STAFF_PASS" | podman exec -i -w /app pasture-privapub /app/PrivaPub admin create-root modr_staff >/dev/null 2>&1 || true
staff_id=$(api "$ADMIN" GET "/clientapi/admin/roots?q=modr_staff" | j "print(next(r['id'] for r in d['items'] if r['userName'] == 'modr_staff'))")
root_id=$(api "$ADMIN" GET "/clientapi/admin/roots?q=$ROOT_USER" | j "print(next(r['id'] for r in d['items'] if r['userName'] == '$ROOT_USER'))")
[ -n "$staff_id" ] && [ -n "$root_id" ] && ok "the administrator reads the logins" || { ko "the logins could not be read"; return 1; }
[ "$(api "$ADMIN" POST "/clientapi/admin/roots/$staff_id/role" -d '{"role":"moderator"}' | j "print(d['role'])")" = moderator ] \
&& ok "a login is made a moderator" || ko "the moderator's role was not given"
[ "$(code_of "$ADMIN" POST "/clientapi/admin/roots/$root_id/role" -d '{"role":"user"}')" = 409 ] \
&& [ "$(api "$ADMIN" GET "/clientapi/admin/roots/$root_id" | j "print(d['role'])")" = admin ] \
&& ok "an administrator can't change its own role, so the last one stays (409)" || ko "the administrator changed its own role"
[ "$(code_of "$ADMIN" POST "/clientapi/admin/roots/$staff_id/role" -d '{"role":"emperor"}')" = 422 ] && ok "an unknown role is refused (422)" || ko "an unknown role was taken"
MOD=$(login modr_staff "$STAFF_PASS")
[ "$(code_of "$MOD" POST "/clientapi/admin/roots/$root_id/role" -d '{"role":"user"}')" = 403 ] \
&& [ "$(code_of "$MOD" GET /clientapi/admin/roots)" = 403 ] && [ "$(code_of "$MOD" POST "/clientapi/admin/roots/$root_id/totp/reset")" = 403 ] \
&& ok "a moderator changes no login and reads none" || ko "a moderator reached the logins"
[ "$(code_of "$MOD" GET /clientapi/moderator/log)" = 200 ] && ok "a moderator reads the staff's log" || ko "the moderator can't read the log"
[ "$(code_of "$ADMIN" POST "/clientapi/admin/roots/$staff_id/totp/reset")" = 200 ] && [ "$(code_of "$ADMIN" POST "/clientapi/admin/roots/$root_id/totp/reset")" = 409 ] \
&& ok "an administrator resets another login's two-factor sign-in, never its own" || ko "the two-factor reset answered wrongly"
echo " a silenced persona"
QT=$(privapub_token modr_quiet); RT=$(privapub_token modr_reader)
quiet_id=$(me_id "$QT"); reader_id=$(me_id "$RT")
curl -s -o /dev/null -X POST -H "Authorization: Bearer $RT" "$P/api/v1/accounts/$quiet_id/follow"
quiet_on_m=$(m_resolve modr_quiet); m_follow "$quiet_on_m"
until_true 30 '[ "$(m_sql "select count(*) from follows where account_id = $m_me and target_account_id = $quiet_on_m")" = 1 ]' \
&& ok "mastouser and a persona follow modr_quiet" || ko "mastouser's follow of modr_quiet was not accepted"
[ "$(moderate "$quiet_id" silence 'Too loud' | j "print(d['silencedAt'] is not None)")" = True ] && ok "the moderator silences it" || ko "modr_quiet was not silenced"
quiet_post=$(post_public "$QT" "a quiet word #modrquiet$run")
quiet_post_id=$(echo "$quiet_post" | j "print(d['id'])"); quiet_uri=$(echo "$quiet_post" | j "print(d['uri'])")
[ "$(in_public "$quiet_post_id")" = False ] && [ "$(in_public "$quiet_post_id" '&local=true')" = False ] && ok "its new post stays out of the public timelines" || ko "the silenced post is in the public timeline"
[ "$(in_tag "$quiet_post_id" "modrquiet$run")" = False ] && ok "and out of its hashtag's" || ko "the silenced post is in its hashtag's timeline"
[ "$(in_home "$RT" "$quiet_post_id")" = True ] && ok "its follower here still gets it" || ko "the follower's home lost the silenced post"
until_true 30 '[ "$(m_holds "$quiet_uri")" = 1 ]' && ok "and so does its follower on Mastodon" || ko "the silenced post never reached Mastodon"
[ "$(code "$P/api/v1/statuses/$quiet_post_id")" = 200 ] && [ "$(curl -s "$P/api/v1/accounts/$quiet_id" | j "print(d['limited'])")" = True ] \
&& ok "it can still be opened, its account says it is limited" || ko "the silenced post or account: $(curl -s "$P/api/v1/accounts/$quiet_id" | head -c 200)"
[ "$(curl -s -H "Authorization: Bearer $RT" "$P/api/v1/accounts/search?q=modr_qu" | j "print(any(a['id'] == '$quiet_id' for a in d))")" = False ] \
&& ok "search finds it no more by a part of its name" || ko "search still finds the silenced persona"
[ "$(warned "$QT" silence)" = True ] && ok "it is warned (moderation_warning, silence)" || ko "modr_quiet was not warned"
moderate "$quiet_id" unsilence >/dev/null
loud_again=$(post_public "$QT" "heard again #modrquiet$run" | j "print(d['id'])")
[ "$(in_public "$loud_again")" = True ] && [ "$(in_public "$quiet_post_id")" = True ] && ok "unsilenced, its posts are in the public timeline again" || ko "the unsilenced posts are not public"
echo " a suspended persona"
HT=$(privapub_token modr_held)
held_id=$(me_id "$HT")
held_post=$(post_public "$HT" "before the suspension $run")
held_post_id=$(echo "$held_post" | j "print(d['id'])"); held_uri=$(echo "$held_post" | j "print(d['uri'])")
held_on_m=$(m_resolve modr_held); m_follow "$held_on_m"
until_true 30 '[ "$(m_holds "$held_uri")" = 1 ] || [ "$(m_sql "select count(*) from follows where account_id = $m_me and target_account_id = $held_on_m")" = 1 ]' \
&& ok "mastouser follows modr_held" || ko "mastouser's follow of modr_held was not accepted"
[ "$(moderate "$held_id" suspend 'Off for a while' | j "print(d['suspendedAt'] is not None)")" = True ] && ok "the moderator suspends it" || ko "modr_held was not suspended"
[ "$(code -H "Authorization: Bearer $HT" "$P/api/v1/accounts/verify_credentials")" = 401 ] \
&& [ "$(code -H "Authorization: Bearer $HT" -X POST "$P/api/v1/statuses" -d 'status=still here?')" = 401 ] \
&& ok "its token answers 401" || ko "the suspended persona's token still works"
[ "$(code "$P/api/v1/accounts/$held_id")" = 404 ] && [ "$(code -H "Authorization: Bearer $RT" "$P/api/v1/accounts/$held_id")" = 404 ] \
&& [ "$(code "$P/api/v1/accounts/$held_id/statuses")" = 404 ] && [ "$(code "$P/api/v1/statuses/$held_post_id")" = 404 ] \
&& [ "$(code "$P/api/v1/accounts/lookup?acct=modr_held")" = 404 ] \
&& ok "its account, posts and lookup answer 404 to everyone else" || ko "the suspended persona is still shown"
[ "$(signed_status https://privapub.test/peasants/modr_held)" = 200 ] && ok "its actor is still served as it was" || ko "the suspended persona's actor is not served"
[ "$(signed_status https://privapub.test/peasants/modr_held/anus)" = 404 ] && [ "$(signed_status "$held_uri")" = 404 ] \
&& ok "its outbox and posts answer 404, even signed" || ko "the suspended persona's outbox or post is served"
"$here/../smoke/oauth.sh" "$P" "$ROOT_USER" "$ROOT_PASS" modr_held read >/dev/null 2>&1 && ko "OAuth still offers the suspended persona" || ok "OAuth no longer offers it"
[ "$(api "$ADMIN" GET /clientapi/avatar/private/list | j "print([a['accountState'] for a in d if a['userName'] == 'modr_held'])")" = "['Suspended']" ] \
&& ok "its login sees it suspended among its personas" || ko "the login does not see the suspension"
[ "$(api "$MOD" GET /clientapi/moderator/accounts/moderated | j "print(any(a['id'] == '$held_id' and a['suspendedAt'] for a in d['items']))")" = True ] \
&& ok "the moderators list it" || ko "the moderated accounts miss modr_held"
[ "$(m_sql "select count(*) from accounts where id = $held_on_m and suspended_at is null")" = 1 ] && ok "Mastodon was told nothing: it still holds the account as it was" || ko "Mastodon changed the account"
moderate "$held_id" unsuspend >/dev/null
[ "$(code -H "Authorization: Bearer $HT" "$P/api/v1/accounts/verify_credentials")" = 401 ] && ok "unsuspended, its old token stays revoked" || ko "the revoked token works again"
HT=$("$here/../smoke/oauth.sh" "$P" "$ROOT_USER" "$ROOT_PASS" modr_held "read write follow" 2>/dev/null | cut -d' ' -f1)
[ "$(code -H "Authorization: Bearer $HT" "$P/api/v1/accounts/verify_credentials")" = 200 ] && [ "$(code "$P/api/v1/statuses/$held_post_id")" = 200 ] \
&& [ "$(signed_status "$held_uri")" = 200 ] && ok "a new token works, and its posts are served again" || ko "modr_held did not come back whole"
[ "$(warned "$HT" suspend)" = True ] && ok "it reads its warning (moderation_warning, suspend)" || ko "modr_held was not warned"
echo " an account elsewhere suspended"
WT=$(privapub_token modr_watch); WH="Authorization: Bearer $WT"
gts_on_p=$(curl -s -H "$WH" "$P/api/v2/search?q=gtsuser@gts.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
watch_on_g=$(g_resolve modr_watch)
curl -s -o /dev/null -X POST -H "$WH" "$P/api/v1/accounts/$gts_on_p/follow"
until_true 20 'gcurl -H "$GH" "$G/api/v1/follow_requests" | j "print(any(a[\"id\"] == \"$watch_on_g\" for a in d))" | grep -q True'
gcurl -o /dev/null -X POST -H "$GH" "$G/api/v1/follow_requests/$watch_on_g/authorize"
until_true 30 '[ "$(curl -s -H "$WH" "$P/api/v1/accounts/relationships?id[]=$gts_on_p" | j "print(d[0][\"following\"])")" = True ]' \
&& ok "modr_watch follows gtsuser" || ko "modr_watch's follow of gtsuser was not accepted"
gcurl -o /dev/null -X POST -H "$GH" "$G/api/v1/statuses" --data-urlencode "status=before the suspension $run" -d visibility=public
until_true 30 'curl -s -H "$WH" "$P/api/v1/timelines/home" | grep -q "before the suspension $run"' && ok "gtsuser's post reaches modr_watch" || ko "gtsuser's post never arrived"
g_post=$(curl -s -H "$WH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'before the suspension $run' in s['content']))")
[ "$(api "$MOD" GET "/clientapi/moderator/accounts?q=gtsuser@gts.test" | j "print(any(a['id'] == '$gts_on_p' and not a['isLocal'] for a in d['items']))")" = True ] \
&& ok "the moderators find it by its handle" || ko "the moderators' search misses gtsuser"
[ "$(moderate "$gts_on_p" suspend | j "print(d['suspendedAt'] is not None)")" = True ] && ok "and suspend it" || ko "gtsuser was not suspended"
[ "$(code -H "$WH" "$P/api/v1/statuses/$g_post")" = 404 ] && [ "$(code -H "$WH" "$P/api/v1/accounts/$gts_on_p")" = 404 ] \
&& [ "$(in_home "$WT" "$g_post")" = False ] && ok "its post and account are gone here, from its follower's home too" || ko "the suspended account is still shown"
gcurl -o /dev/null -X POST -H "$GH" "$G/api/v1/statuses" --data-urlencode "status=during the suspension $run" -d visibility=public
curl -s -o /dev/null -X POST -H "$WH" "$P/api/v1/statuses" --data-urlencode "status=@gtsuser@gts.test are you there $run" -d visibility=direct
sleep 20
[ "$(p_mongo "print(db.Post.countDocuments({ContentHtml: /during the suspension $run/}))")" = 0 ] && ok "what it sends meanwhile is dropped" || ko "the suspended account's new post was taken in"
gcurl -H "$GH" "$G/api/v1/conversations" | grep -q "are you there $run" && ko "a direct message reached the suspended account" || ok "and nothing is delivered to it"
moderate "$gts_on_p" unsuspend >/dev/null
[ "$(code -H "$WH" "$P/api/v1/statuses/$g_post")" = 200 ] && [ "$(in_home "$WT" "$g_post")" = True ] && ok "unsuspended, its earlier post shows again" || ko "the earlier post did not come back"
gcurl -o /dev/null -X POST -H "$GH" "$G/api/v1/statuses" --data-urlencode "status=after the suspension $run" -d visibility=public
until_true 30 'curl -s -H "$WH" "$P/api/v1/timelines/home" | grep -q "after the suspension $run"' && ok "and what it sends arrives again" || ko "gtsuser's post after the suspension never arrived"
echo " a silenced server"
[ "$(in_public "$g_post" '&remote=true')" = True ] && ok "gtsuser's post is in the federated timeline" || ko "gtsuser's post is not in the federated timeline"
api "$ADMIN" POST /clientapi/admin/domainblocks/insert -d '{"domain":"gts.test","suspend":false,"privateComment":"pasture moderation"}' >/dev/null
[ "$(in_public "$g_post" '&remote=true')" = False ] && [ "$(curl -s -H "$WH" "$P/api/v1/accounts/$gts_on_p" | j "print(d['limited'])")" = True ] \
&& [ "$(in_home "$WT" "$g_post")" = True ] && ok "its server silenced, the post leaves it (its follower keeps it), the account is limited" || ko "the silenced server's post is still listed"
api "$ADMIN" POST "/clientapi/admin/domainblocks/delete?domain=gts.test" >/dev/null
[ "$(in_public "$g_post" '&remote=true')" = True ] && ok "and is back when the block goes" || ko "the post stayed out after the block went"
echo " a banned login"
echo "$STAFF_PASS" | podman exec -i -w /app pasture-privapub /app/PrivaPub admin create-root modr_banned >/dev/null 2>&1 || true
B1=$(login_token modr_banned "$STAFF_PASS" modr_ban1); B2=$(login_token modr_banned "$STAFF_PASS" modr_ban2)
ban1_id=$(me_id "$B1"); ban2_id=$(me_id "$B2")
banned_id=$(api "$ADMIN" GET "/clientapi/admin/roots?q=modr_banned" | j "print(next(r['id'] for r in d['items'] if r['userName'] == 'modr_banned'))")
[ -n "$ban1_id" ] && [ -n "$ban2_id" ] && [ -n "$banned_id" ] && ok "a login with two personas" || { ko "the banned login's personas"; return 1; }
ban2_on_m=$(m_resolve modr_ban2)
ban1_post=$(post_public "$B1" "before the ban $run" | j "print(d['id'])")
[ "$(code_of "$ADMIN" POST /clientapi/admin/ban/users -d "{\"userIdList\":[\"$banned_id\"]}")" = 200 ] && ok "the administrator bans it" || ko "the ban was refused"
[ "$(code -H "Authorization: Bearer $B1" "$P/api/v1/accounts/verify_credentials")" = 401 ] && [ "$(code -H "Authorization: Bearer $B2" "$P/api/v1/accounts/verify_credentials")" = 401 ] \
&& ok "both personas' tokens answer 401" || ko "a banned persona's token still works"
[ "$(code "$P/api/v1/accounts/$ban1_id")" = 404 ] && [ "$(code "$P/api/v1/statuses/$ban1_post")" = 404 ] && [ "$(in_public "$ban1_post")" = False ] \
&& ok "their accounts and posts are shown to nobody" || ko "a banned persona is still shown"
m_follow "$ban2_on_m"
sleep 20
[ "$(m_sql "select count(*) from follow_requests where account_id = $m_me and target_account_id = $ban2_on_m")" = 1 ] \
&& [ "$(m_sql "select count(*) from follows where account_id = $m_me and target_account_id = $ban2_on_m")" = 0 ] \
&& ok "they send nothing: mastouser's follow of modr_ban2 waits unanswered" || ko "a banned persona answered a follow"
[ "$(p_mongo "print(db.Avatar.countDocuments({_id: {\$in: [ObjectId('$ban1_id'), ObjectId('$ban2_id')]}, \$or: [{SuspendedAt: {\$ne: null}}, {SilencedAt: {\$ne: null}}, {BannedAt: {\$ne: null}}]}) + db.Post.countDocuments({GroupUserId: {\$in: ['$ban1_id', '$ban2_id']}, AuthorSuspended: true}))")" = 0 ] \
&& [ "$(api "$MOD" GET /clientapi/moderator/accounts/moderated | j "print(any(a['id'] in ('$ban1_id', '$ban2_id') for a in d['items']))")" = False ] \
&& ok "nothing is written on either persona, and the moderators see neither" || ko "the ban was written on the personas"
[ "$(code_of "$ADMIN" POST /clientapi/admin/unban/users -d "{\"userIdList\":[\"$banned_id\"]}")" = 200 ] \
&& [ "$(code "$P/api/v1/accounts/$ban1_id")" = 200 ] && [ "$(code "$P/api/v1/statuses/$ban1_post")" = 200 ] \
&& ok "unbanned, both are shown again" || ko "the unbanned personas are not back"
mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/accounts/$ban2_on_m/unfollow"
echo " deletions"
doom="modr_doom_$run"
DT=$(privapub_token "$doom"); doom_id=$(me_id "$DT")
doom_on_m=$(m_resolve "$doom"); m_follow "$doom_on_m"
doom_on_g=$(g_resolve "$doom"); gcurl -o /dev/null -X POST -H "$GH" "$G/api/v1/accounts/$doom_on_g/follow"
until_true 30 '[ "$(m_sql "select count(*) from follows where account_id = $m_me and target_account_id = $doom_on_m")" = 1 ] && [ "$(gcurl -H "$GH" "$G/api/v1/accounts/relationships?id[]=$doom_on_g" | j "print(d[0][\"following\"])")" = True ]' \
&& ok "mastouser and gtsuser follow $doom" || ko "the follows of $doom were not accepted"
[ "$(code_of "$ADMIN" POST "/clientapi/admin/personas/$doom_id/delete" -d '{"password":"not it"}')" = 403 ] \
&& [ "$(code_of "$MOD" POST "/clientapi/admin/personas/$doom_id/delete" -d "{\"password\":\"$STAFF_PASS\"}")" = 403 ] \
&& ok "deleting a persona needs an administrator and its password" || ko "a persona's deletion was let through"
[ "$(code_of "$ADMIN" POST "/clientapi/admin/personas/$doom_id/delete" -d "{\"password\":\"$ROOT_PASS\",\"note\":\"pasture\"}")" = 200 ] \
&& gone_unsigned "https://privapub.test/peasants/$doom" && ok "the administrator deletes it: its actor answers 410" || ko "the persona was not deleted"
until_true 30 '[ "$(m_sql "select count(*) from accounts where id = $doom_on_m and suspended_at is null")" = 0 ]' && ok "it is gone on Mastodon" || ko "Mastodon still holds the deleted persona"
until_true 30 '[ "$(gcurl -H "$GH" "$G/api/v1/accounts/$doom_on_g" | j "print(d is None or \"error\" in d or d.get(\"suspended\") is True)")" = True ]' \
&& ok "and on GoToSocial" || ko "GoToSocial still shows the deleted persona: $(gcurl -H "$GH" "$G/api/v1/accounts/$doom_on_g" | head -c 200)"
self="modr_self_$run"
privapub_token "$self" >/dev/null
self_id=$(api "$ADMIN" GET /clientapi/avatar/private/list | j "print(next(a['id'] for a in d if a['userName'] == '$self'))")
[ "$(code_of "$ADMIN" POST /clientapi/avatar/private/delete -d "{\"avatarId\":\"$self_id\",\"password\":\"not it\"}")" = 403 ] \
&& [ "$(code_of "$ADMIN" POST /clientapi/avatar/private/delete -d "{\"avatarId\":\"$self_id\",\"password\":\"$ROOT_PASS\"}")" = 200 ] \
&& gone_unsigned "https://privapub.test/peasants/$self" && ok "a login deletes its own persona with its password" || ko "the login could not delete its persona"
group="modr_grp_$run"
group_id=$(api "$ADMIN" POST /clientapi/group/insert -d "{\"avatarId\":\"$reader_id\",\"userName\":\"$group\",\"name\":\"Pasture group $run\",\"isCommunity\":true}" | j "print(d['id'])")
[ "$(code_of "$ADMIN" POST /clientapi/group/delete -d "{\"avatarId\":\"$reader_id\",\"groupId\":\"$group_id\",\"password\":\"not it\"}")" = 403 ] \
&& [ "$(code_of "$ADMIN" POST /clientapi/group/delete -d "{\"avatarId\":\"$reader_id\",\"groupId\":\"$group_id\",\"password\":\"$ROOT_PASS\"}")" = 200 ] \
&& gone_unsigned "https://privapub.test/peasants/$group" && ok "a group's owner deletes it" || ko "the owner could not delete the group"
group2_id=$(api "$ADMIN" POST /clientapi/group/insert -d "{\"avatarId\":\"$reader_id\",\"userName\":\"${group}b\",\"name\":\"Pasture group b $run\",\"isCommunity\":true}" | j "print(d['id'])")
[ "$(code_of "$ADMIN" POST "/clientapi/admin/groups/$group2_id/delete" -d "{\"password\":\"$ROOT_PASS\"}")" = 200 ] \
&& gone_unsigned "https://privapub.test/peasants/${group}b" && ok "and an administrator deletes another" || ko "the administrator could not delete the group"
echo " posts removed"
LT=$(privapub_token modr_loud); loud_id=$(me_id "$LT")
loud_on_m=$(m_resolve modr_loud); m_follow "$loud_on_m"
until_true 30 '[ "$(m_sql "select count(*) from follows where account_id = $m_me and target_account_id = $loud_on_m")" = 1 ]' && ok "mastouser follows modr_loud" || ko "mastouser's follow of modr_loud was not accepted"
first=$(post_public "$LT" "removed directly $run"); first_id=$(echo "$first" | j "print(d['id'])"); first_uri=$(echo "$first" | j "print(d['uri'])")
second=$(post_public "$LT" "removed through a report $run"); second_id=$(echo "$second" | j "print(d['id'])"); second_uri=$(echo "$second" | j "print(d['uri'])")
until_true 30 '[ "$(m_holds "$first_uri")" = 1 ] && [ "$(m_holds "$second_uri")" = 1 ]' && ok "both its posts reach Mastodon" || ko "modr_loud's posts never reached Mastodon"
[ "$(code_of "$MOD" POST /clientapi/moderator/posts/remove -d "{\"postId\":\"$first_id\",\"note\":\"pasture\",\"warning\":\"That one broke a rule.\"}")" = 200 ] \
&& [ "$(code "$P/api/v1/statuses/$first_id")" = 404 ] && ok "the moderator removes one: it answers 404 here" || ko "the post was not removed"
until_true 30 '[ "$(m_holds "$first_uri")" = 0 ]' && ok "and is gone on Mastodon" || ko "Mastodon still holds the removed post"
report_id=$(curl -s -X POST -H "Authorization: Bearer $RT" "$P/api/v1/reports" -d "account_id=$loud_id" -d "status_ids[]=$second_id" --data-urlencode "comment=pasture $run" | j "print(d['id'])")
[ "$(code_of "$MOD" POST "/clientapi/moderator/reports/$report_id/resolve" -d '{"action":"remove_posts","note":"pasture"}')" = 200 ] \
&& [ "$(code "$P/api/v1/statuses/$second_id")" = 404 ] && ok "a report resolved by removing what it names" || ko "the report's post was not removed"
until_true 30 '[ "$(m_holds "$second_uri")" = 0 ]' && ok "gone on Mastodon too" || ko "Mastodon still holds the reported post"
[ "$(api "$MOD" GET "/clientapi/moderator/reports?resolved=true" | j "print(any(r['id'] == '$report_id' for r in d))")" = True ] && ok "and the report is resolved" || ko "the report is not resolved"
[ "$(warned "$LT" delete_statuses)" = True ] && ok "its author is warned (moderation_warning, delete_statuses)" || ko "modr_loud was not warned"
[ "$(code_of "$MOD" POST /clientapi/moderator/posts/remove -d "{\"postId\":\"$g_post\"}")" = 200 ] && [ "$(code -H "$WH" "$P/api/v1/statuses/$g_post")" = 404 ] \
&& ok "a copy of gtsuser's post is taken down here" || ko "the remote copy was not taken down"
echo " the staff's log"
log=$(api "$ADMIN" GET "/clientapi/moderator/log?limit=100")
missing=$(echo "$log" | j "
seen={i['action'] for i in d['items']}
print(' '.join(sorted({'root.role','root.reset_totp','persona.silence','persona.unsilence','persona.suspend','persona.unsuspend','account.suspend','account.unsuspend','domain_block.insert','domain_block.delete','root.ban','root.unban','persona.delete','group.delete','post.remove','report.resolve'} - seen)))")
[ -z "$missing" ] && [ -n "$log" ] && ok "it lists every act of the scenario" || ko "the log misses: $missing"
[ "$(echo "$log" | j "print(any(i['action'] == 'report.resolve' and i.get('reportId') == '$report_id' and (i.get('note') or '').startswith('remove_posts') for i in d['items']) and any(i['action'] == 'post.remove' and i.get('reportId') == '$report_id' for i in d['items']))")" = True ] \
&& ok "a resolution says what it did, and the removal names its report" || ko "the resolution's entries: $(echo "$log" | j "print([i for i in d['items'] if i['action'] in ('report.resolve','post.remove')][:3])")"
[ "$(echo "$log" | j "print(any(i['action'] == 'persona.suspend' and i.get('by') == 'modr_staff' and i['targetKind'] == 'persona' for i in d['items']))")" = True ] \
&& [ "$(echo "$log" | j "print(not any(i['targetKind'] == 'root' and '@' in (i.get('targetLabel') or '') for i in d['items']))")" = True ] \
&& ok "each act names who did it, and a login's entries name no persona" || ko "the log pairs a login with a persona"
[ "$(api "$MOD" GET "/clientapi/moderator/log?limit=100" | j "print(not any(i['targetKind'] == 'root' for i in d['items']))")" = True ] \
&& ok "a moderator reads none of the logins' entries" || ko "the moderator read a login's entry"
echo "$log" | grep -qE '127\.0\.0\.1|11\.42\.|::1' && ko "the log holds an address" || ok "and it holds no address"
for id in $(api "$MOD" GET /clientapi/moderator/accounts/moderated | j "print(' '.join(a['id'] for a in d['items']))"); do
moderate "$id" unsuspend >/dev/null; moderate "$id" unsilence >/dev/null
done
+29 -3
View File
@@ -1,8 +1,9 @@
# Backup and restore (owner decisions 2026-10-07), on the live pasture and through the administrator's endpoints: the
# server is backed up, then life goes on: alice_restore deletes a post and makes another, mastouser follows her, she
# blocks bob_restore, and carol_restore is made. The backup is restored: PrivaPub stops, restores it as it starts again,
# and nothing protective is undone: the deleted post stays gone, the post made since answers as deleted, mastouser still
# follows her, the block holds and carol's name stays taken. Every session ends, so the scenario signs in again, and at
# blocks bob_restore, carol_restore is made, and the staff suspend dave_restore, silence mrestore on Mastodon and take
# down a post of alice's. The backup is restored: PrivaPub stops, restores it as it starts again, and nothing protective
# is undone: the deleted post stays gone, the post made since answers as deleted, mastouser still follows her, the block
# holds, carol's name stays taken, and the suspension, the silence and the takedown hold (R6). Every session ends, so the scenario signs in again, and at
# the end the town's PrivaPub accounts too (town.sh renew privapub). Run it alone: it restores the whole server. Needs
# the mastodon peer.
M=https://mastodon.test:6443
@@ -10,6 +11,7 @@ mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; }
. "$here/peers/mastodon.sh"
m_rails() { podman exec pasture-mastodon bin/rails runner "$1" 2>/dev/null | tail -1; }
p_mongo() { podman exec pasture-mongo mongosh --quiet PrivaPub --eval "$1"; }
staff() { local method=$1 path=$2; shift 2; curl -s -X "$method" -H "$RH" -H 'Content-Type: application/json' "$P$path" "$@"; }
echo "restore"
podman exec -w /app pasture-privapub /app/PrivaPub admin promote "$ROOT_USER" >/dev/null 2>&1 || true
@@ -26,6 +28,14 @@ alice_uri=$(echo "$alice" | j "print(d['url'])" | sed 's|/@|/peasants/|')
bob_id=$(curl -s -H "Authorization: Bearer $BT" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
regret=$(curl -s -X POST -H "$AH" "$P/api/v1/statuses" -d "status=a post regretted later $run&visibility=public")
regret_id=$(echo "$regret" | j "print(d['id'])"); regret_uri=$(echo "$regret" | j "print(d['uri'])")
DT=$(privapub_token dave_restore)
dave_id=$(curl -s -H "Authorization: Bearer $DT" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$dave_id\",\"action\":\"unsuspend\"}" >/dev/null
down_id=$(curl -s -X POST -H "$AH" "$P/api/v1/statuses" -d "status=a post the staff take down later $run&visibility=public" | j "print(d['id'])")
mastodon_user mrestore
mr_id=$(curl -s -H "$AH" "$P/api/v2/search?q=mrestore@mastodon.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$mr_id\",\"action\":\"unsilence\"}" >/dev/null
[ -n "$dave_id" ] && [ -n "$down_id" ] && [ -n "$mr_id" ] && ok "dave_restore, a post to take down and mrestore on Mastodon" || ko "the moderation's subjects: $dave_id $down_id $mr_id"
# mastouser follows nobody here yet: the follow comes after the backup
m_follows() { m_rails "puts Follow.exists?(account: Account.find_local(\"mastouser\"), target_account: Account.find_by(uri: \"$alice_uri\")) ? \"True\" : \"False\""; }
alice_on_m=$(mcurl -H "Authorization: Bearer $(mastodon_token)" "$M/api/v2/search?q=@alice_restore@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
@@ -57,6 +67,13 @@ curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$bob_id/block"
carol="carol_restore_$run"
curl -s -o /dev/null -X POST -H "$RH" -H 'Content-Type: application/json' "$P/clientapi/avatar/private/insert" \
-d "{\"userName\":\"$carol\",\"name\":\"carol\",\"biography\":\"made after the backup\"}"
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$dave_id\",\"action\":\"suspend\",\"note\":\"after the backup\"}" >/dev/null
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$mr_id\",\"action\":\"silence\",\"note\":\"after the backup\"}" >/dev/null
staff POST /clientapi/moderator/posts/remove -d "{\"postId\":\"$down_id\",\"note\":\"after the backup\"}" >/dev/null
[ "$(staff GET "/clientapi/moderator/accounts/$dave_id" | j "print(bool(d.get('suspendedAt')))")" = True ] \
&& [ "$(staff GET "/clientapi/moderator/accounts/$mr_id" | j "print(bool(d.get('silencedAt')))")" = True ] \
&& [ "$(curl -s -o /dev/null -w '%{http_code}' -H "$AH" "$P/api/v1/statuses/$down_id")" = 404 ] \
&& ok "the staff suspend dave_restore, silence mrestore and take a post down after the backup" || ko "the staff's acts after the backup"
echo " restored"
[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$RH" -H 'Content-Type: application/json' "$P/clientapi/admin/backups/$backup/restore" \
@@ -81,6 +98,15 @@ gone_unsigned "$since_uri" && ok "the post made after the backup answers as gone
&& ok "alice still blocks bob_restore" || ko "the block made after the backup was undone"
[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$RH" -H 'Content-Type: application/json' "$P/clientapi/avatar/private/insert" \
-d "{\"userName\":\"$carol\",\"name\":\"carol\",\"biography\":\"again\"}")" != "200" ] && ok "carol's name stays taken" || ko "carol's name was free again"
[ "$(staff GET "/clientapi/moderator/accounts/$dave_id" | j "print(bool(d.get('suspendedAt')))")" = True ] \
&& ok "dave_restore stays suspended" || ko "the suspension made after the backup was undone"
[ -z "$(privapub_token dave_restore)" ] && ok "and gets no token" || ko "the suspended persona got a token after the restore"
[ "$(staff GET "/clientapi/moderator/accounts/$mr_id" | j "print(bool(d.get('silencedAt')))")" = True ] \
&& ok "mrestore stays silenced" || ko "the silence made after the backup was undone"
[ "$(curl -s -o /dev/null -w '%{http_code}' -H "$AH" "$P/api/v1/statuses/$down_id")" = 404 ] \
&& ok "the post taken down stays down" || ko "the post taken down after the backup came back"
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$dave_id\",\"action\":\"unsuspend\"}" >/dev/null
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$mr_id\",\"action\":\"unsilence\"}" >/dev/null
status=$(podman exec -w /app pasture-privapub /app/PrivaPub admin restore --status 2>/dev/null | grep -o "personas [^,;]*" | head -1)
echo "$status" | grep -q "$carol" && ok "the report names carol among the personas made since" || ko "the report: $status"