# The Mastodon API made real (completion program R8), with Mastodon. mastouser follows feat_alice, who features a hashtag: # her /tattoos serves it and Mastodon shows it among her account's featured tags (her Add{Hashtag}), and unfeaturing it # takes it away there (Remove{Hashtag}). feat_alice follows mastouser with notify=true, and a tag he features shows among # his featured tags here; his next post gives her a `status` notification; his direct message to her is told on her # `direct` stream, as server-sent events and on the WebSocket, as a `conversation` event. Her endorsement of him and her # private note on him are hers alone: feat_bob, a sibling persona of the same login, sees neither, nobody else is told her # endorsements, and feat_bob is never suggested to her, though she favourited his post. The stubs answer: translate 422, # annual reports empty, the Mastodon admin API 403. Needs the mastodon peer (batch A); run after emoji. M=https://mastodon.test:6443 mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; } . "$here/peers/mastodon.sh" code() { curl -s -o /dev/null -w '%{http_code}' "$@"; } # m_signed_body : one of our documents as Mastodon's instance actor reads it, signed (SecureMode answers unsigned 401) m_signed_body() { 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.body_with_limit }" 2>/dev/null | tail -1; } m_featured() { mcurl -H "$MH" "$M/api/v1/accounts/$1/featured_tags" | j "print(any((t.get('name') or '').lower() == '$2' for t in d or []))"; } p_featured() { curl -s -H "$AH" "$P/api/v1/accounts/$1/featured_tags" | j "print(any((t.get('name') or '').lower() == '$2' for t in d or []))"; } echo "featured" run=$(date +%s) tag="pasture$run"; mtag="mastopasture$run" AT=$(privapub_token feat_alice); AH="Authorization: Bearer $AT" BT=$(privapub_token feat_bob); BH="Authorization: Bearer $BT" MT=$(mastodon_token); MH="Authorization: Bearer $MT" [ -n "$AT" ] && [ -n "$BT" ] && [ -n "$MT" ] && ok "tokens for feat_alice, feat_bob (her sibling, the same login) and mastouser" \ || { ko "a token is missing"; return 1; } alice_id=$(curl -s -H "$AH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])") bob_id=$(curl -s -H "$BH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])") echo " follows" alice_on_m=$(mcurl -H "$MH" "$M/api/v2/search?q=@feat_alice@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/accounts/$alice_on_m/follow" until_true 30 '[ "$(curl -s "$P/api/v1/accounts/$alice_id" | j "print(d[\"followers_count\"])")" -ge 1 ]' \ && ok "mastouser follows feat_alice" || ko "mastouser's follow never arrived" m_on_p=$(curl -s -H "$AH" "$P/api/v2/search?q=mastouser@mastodon.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") rel=$(curl -s -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/follow" -d notify=true -d 'languages[]=en') [ "$(echo "$rel" | j "print(d['notifying'] is True and d['languages'] == ['en'])")" = True ] \ && ok "feat_alice follows mastouser with notify and languages (the relationship says so)" || ko "the follow's options: $(echo "$rel" | head -c 300)" until_true 30 '[ "$(curl -s -H "$AH" "$P/api/v1/accounts/relationships?id[]=$m_on_p" | j "print(d[0][\"following\"])")" = True ]' \ && ok "mastouser accepts feat_alice" || ko "feat_alice's follow was not accepted" echo " her featured tag" featured=$(curl -s -X POST -H "$AH" "$P/api/v1/featured_tags" -d "name=$tag") featured_id=$(echo "$featured" | j "print(d['id'])") [ "$(echo "$featured" | j "print(d['name'] == '$tag' and d['statuses_count'] == '0')")" = True ] && ok "feat_alice features #$tag" \ || ko "featuring was refused: $(echo "$featured" | head -c 300)" [ "$(curl -s -H "$AH" "$P/api/v1/featured_tags" | j "print(any(t['id'] == '$featured_id' for t in d))")" = True ] \ && [ "$(curl -s "$P/api/v1/accounts/$alice_id/featured_tags" | j "print(any(t['name'] == '$tag' for t in d))")" = True ] \ && ok "her featured tags list it, anonymously too" || ko "her featured tags miss it" [ "$(curl -s "$P/api/v2/instance" | j "print(d['configuration']['accounts']['max_featured_tags'])")" = 10 ] \ && ok "the instance API allows ten" || ko "max_featured_tags: $(curl -s "$P/api/v2/instance" | j "print(d['configuration']['accounts'])")" [ "$(m_signed_body https://privapub.test/peasants/feat_alice/tattoos | j "print(any(i['type'] == 'Hashtag' and i['name'] == '#$tag' for i in d['orderedItems']))")" = True ] \ && ok "/tattoos serves it as a Hashtag (signed)" || ko "/tattoos: $(m_signed_body https://privapub.test/peasants/feat_alice/tattoos | head -c 300)" until_true 30 '[ "$(m_featured "$alice_on_m" "$tag")" = True ]' \ && ok "Mastodon shows #$tag among feat_alice's featured tags" || ko "Mastodon's featured tags of feat_alice: $(mcurl -H "$MH" "$M/api/v1/accounts/$alice_on_m/featured_tags" | head -c 300)" [ "$(code -X DELETE -H "$AH" "$P/api/v1/featured_tags/$featured_id")" = 200 ] && ok "feat_alice unfeatures it" || ko "unfeaturing was refused" until_true 30 '[ "$(m_featured "$alice_on_m" "$tag")" = False ]' \ && ok "Mastodon no longer shows it" || ko "Mastodon still shows #$tag" echo " his featured tag" m_featured_id=$(mcurl -X POST -H "$MH" "$M/api/v1/featured_tags" -d "name=$mtag" | j "print(d['id'])") [ -n "$m_featured_id" ] && ok "mastouser features #$mtag" || ko "Mastodon refused the featured tag" until_true 30 '[ "$(p_featured "$m_on_p" "$mtag")" = True ]' \ && ok "feat_alice reads #$mtag among mastouser's featured tags" || ko "his featured tags here: $(curl -s -H "$AH" "$P/api/v1/accounts/$m_on_p/featured_tags" | head -c 300)" mcurl -o /dev/null -X DELETE -H "$MH" "$M/api/v1/featured_tags/$m_featured_id" until_true 30 '[ "$(p_featured "$m_on_p" "$mtag")" = False ]' \ && ok "his unfeaturing reaches PrivaPub" || ko "#$mtag is still featured here" echo " notify" mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses" --data-urlencode "status=notify me, PrivaPub $run" -d visibility=public -d language=en until_true 30 '[ "$(curl -s -H "$AH" "$P/api/v1/notifications?types[]=status" | j "print(any(\"notify me, PrivaPub $run\" in n[\"status\"][\"content\"] for n in d))")" = True ]' \ && ok "his post gives feat_alice a status notification" || ko "no status notification: $(curl -s -H "$AH" "$P/api/v1/notifications?limit=5" | head -c 300)" echo " the direct stream" curl -s -N -H "$AH" "$P/api/v1/streaming/direct" > "$work/sse.txt" & sse=$! cat > "$work/ws.py" <<'PY' import asyncio, json, sys, websockets async def main(url, token, seconds): async with websockets.connect(url, subprotocols=[token]) as ws: await ws.send(json.dumps({"type": "subscribe", "stream": "direct"})) try: while True: message = json.loads(await asyncio.wait_for(ws.recv(), timeout=seconds)) print(json.dumps(message), flush=True) except asyncio.TimeoutError: pass asyncio.run(main(sys.argv[1], sys.argv[2], float(sys.argv[3]))) PY python3 -I "$work/ws.py" "ws://127.0.0.1:${PASTURE_PORT:-6971}/api/v1/streaming" "$AT" 40 > "$work/ws.txt" 2>&1 & ws=$! sleep 3 mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses" --data-urlencode "status=@feat_alice@privapub.test a secret, PrivaPub $run" -d visibility=direct until_true 30 'grep -q "^event: conversation" "$work/sse.txt"' && ok "the SSE direct stream tells a conversation event" \ || ko "the SSE direct stream: $(head -c 300 "$work/sse.txt")" until_true 20 '[ "$(python3 -c "import json,sys for l in open(sys.argv[1]): m=json.loads(l) if m.get(\"event\")==\"conversation\" and m.get(\"stream\")==[\"direct\"] and \"a secret, PrivaPub $run\" in json.loads(m[\"payload\"])[\"last_status\"][\"content\"]: print(True); break" "$work/ws.txt" 2>/dev/null)" = True ]' \ && ok "the WebSocket's direct subscription tells it, with the message" || ko "the WebSocket: $(head -c 300 "$work/ws.txt")" kill "$sse" "$ws" 2>/dev/null; wait "$sse" "$ws" 2>/dev/null echo " endorsements and notes stay hers" [ "$(curl -s -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/endorse" | j "print(d['endorsed'])")" = True ] \ && [ "$(curl -s -H "$AH" "$P/api/v1/endorsements" | j "print(any(a['id'] == '$m_on_p' for a in d))")" = True ] \ && ok "feat_alice endorses mastouser" || ko "the endorsement did not hold" [ "$(curl -s -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/note" --data-urlencode "comment=met in the pasture $run" | j "print(d['note'])")" = "met in the pasture $run" ] \ && ok "her private note on him" || ko "the note was not kept" [ "$(curl -s -H "$BH" "$P/api/v1/accounts/relationships?id[]=$m_on_p" | j "print(d[0]['note'] == '' and d[0]['endorsed'] is False)")" = True ] \ && [ "$(curl -s -H "$BH" "$P/api/v1/endorsements" | j "print(d == [])")" = True ] \ && ok "feat_bob, of the same login, sees neither her note nor her endorsement" || ko "a sibling sees her note or endorsement" [ "$(curl -s -H "$BH" "$P/api/v1/accounts/$alice_id/endorsements" | j "print(d == [])")" = True ] \ && [ "$(curl -s "$P/api/v1/accounts/$alice_id/endorsements" | j "print(d == [])")" = True ] \ && [ "$(curl -s -H "$AH" "$P/api/v1/accounts/$alice_id/endorsements" | j "print(any(a['id'] == '$m_on_p' for a in d))")" = True ] \ && ok "her endorsements are told to her alone" || ko "her endorsements are told to others" bob_post=$(curl -s -X POST -H "$BH" "$P/api/v1/statuses" --data-urlencode "status=bob's own post $run" -d visibility=public | j "print(d['id'])") curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/statuses/$bob_post/favourite" [ "$(curl -s -H "$AH" "$P/api/v2/suggestions?limit=80" | j "print(all(s['account']['id'] not in ('$bob_id', '$alice_id') for s in d))")" = True ] \ && ok "feat_bob is never suggested to her, though she favourited his post" || ko "a sibling is suggested: $(curl -s -H "$AH" "$P/api/v2/suggestions" | head -c 300)" [ "$(curl -s -H "$AH" "$P/api/v1/accounts/familiar_followers?id[]=$m_on_p" | j "print(d == [{'id': '$m_on_p', 'accounts': []}])")" = True ] \ && ok "familiar followers stay empty" || ko "familiar followers: $(curl -s -H "$AH" "$P/api/v1/accounts/familiar_followers?id[]=$m_on_p")" echo " stubs" [ "$(code -X POST -H "$AH" "$P/api/v1/statuses/$bob_post/translate")" = 422 ] && ok "translate answers 422" || ko "translate answers otherwise" [ "$(curl -s -H "$AH" "$P/api/v1/annual_reports" | j "print(d == {'annual_reports': [], 'accounts': [], 'statuses': []})")" = True ] \ && ok "annual reports answer none" || ko "annual reports: $(curl -s -H "$AH" "$P/api/v1/annual_reports")" [ "$(code -H "$AH" "$P/api/v1/admin/accounts")" = 403 ] && [ "$(curl -s "$P/api/v1/admin/reports" | j "print(d['error'])")" = "This action is not allowed" ] \ && ok "the Mastodon admin API answers 403" || ko "the admin API answers $(code -H "$AH" "$P/api/v1/admin/accounts")" curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/unendorse" curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/note" -d comment=