tools/pasture/town/ (run through tools/pasture/town.sh) seeds a fake community across every running peer and checks that all of them, and PrivaPub, agree on what happened: - drivers per platform on four dialect bases (Mastodon API, Misskey API, Lemmy API, PrivaPub with /clientapi), each with a selftest against its own server; what a server holds is read from its database, never by making it fetch; - a deterministic generator (specs/village.json: 23 accounts on seven servers, roots with several personas, circles and communities, a cross-server follow graph, posts of every kind and visibility, reply rounds, likes, boosts, reactions, votes, edits, deletes, blocks, mutes and a report) and a seeder that keeps a ledger of what happened; - a sweep that expects delivery and confinement per server, what each account sees, counts, threads, edits, deletes, follows and privacy rows (sibling keys, published days, canary root credentials in every peer's database, located posts that never leave), with what the peers do on purpose modelled (Misskey drops orphan replies, Lemmy keeps only community content, edits go to the post's own audience); - known gaps (gaps.json) turn failures into xfail and passes into xpass; a self-contained report.html, and docs/INTEROP-BACKLOG.md. The pasture moves to a public-looking subnet (peers with no private address switch can join), takes PASTURE_PORT when 6971 is in use, adds peers to a running pasture (run.sh add, Caddy recreated with its CA kept), removes its volumes on down, writes every scenario check to out/scenarios.jsonl, serves decePub as decepub.test for its e2e tests, lifts GoToSocial's and Lemmy's own rate limits, trusts Caddy in Mastodon (TRUSTED_PROXY_IP) and gains Hollo (Fedify), whose one login owning several accounts is the nearest peer to PrivaPub's personas. The first village found the four PrivaPub bugs fixed in the commits before this one; the second run, on the fixed server, passes 2319 checks with 11 failures left, all between peers or from Lemmy's send worker, which the seeder now warms up first. ROADMAP records the owner's decisions of 2026-10-04 (the town, and P9 back from the cut list). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
155 lines
6.2 KiB
Python
155 lines
6.2 KiB
Python
"""Each driver against its own server, before any federation is trusted: two accounts of the platform make, like,
|
|
boost, reply to, vote on, edit and delete objects, and the database view (`stored`) must agree with what was done and
|
|
with the API. A failing selftest means the driver is wrong, not the federation."""
|
|
import time
|
|
import traceback
|
|
|
|
from dialects import driver
|
|
from dialects.base import Account, PostSpec, Unsupported
|
|
import state
|
|
|
|
PASSWORD = "Town-Selftest-Pass-7!"
|
|
|
|
|
|
def wait(fn, tries=20, delay=1.0):
|
|
for _ in range(tries):
|
|
value = fn()
|
|
if value:
|
|
return value
|
|
time.sleep(delay)
|
|
return fn()
|
|
|
|
|
|
class Result:
|
|
def __init__(self, peer):
|
|
self.peer = peer
|
|
self.passed = 0
|
|
self.failed = 0
|
|
self.skipped = 0
|
|
|
|
def check(self, cond, what):
|
|
if cond:
|
|
self.passed += 1
|
|
print(f" ok {what}")
|
|
else:
|
|
self.failed += 1
|
|
print(f" FAIL {what}")
|
|
|
|
def skip(self, what):
|
|
self.skipped += 1
|
|
print(f" n/a {what}")
|
|
|
|
|
|
def accounts(peer):
|
|
root = "st_root" if peer == "privapub" else None
|
|
# Mastodon reserves names that contain its own, so the platform goes in as its two-letter code
|
|
import gen
|
|
short = gen.SHORT.get(peer, peer[:2])
|
|
return [Account(peer, f"st_a_{short}", PASSWORD, name="Selftest A", bio="selftest", root=root,
|
|
fields=[("site", "pasture")], avatar_seed=f"st-a-{peer}"),
|
|
Account(peer, f"st_b_{short}", PASSWORD, name="Selftest B", root=root)]
|
|
|
|
|
|
def run(peers):
|
|
total_failed = 0
|
|
for peer in peers:
|
|
print(peer)
|
|
r = Result(peer)
|
|
try:
|
|
if peer == "lemmy":
|
|
lemmy(r)
|
|
else:
|
|
microblog(peer, r)
|
|
except Exception as e:
|
|
r.check(False, f"selftest stopped: {e!r}")
|
|
traceback.print_exc()
|
|
print(f" {r.passed} passed, {r.failed} failed, {r.skipped} not applicable")
|
|
total_failed += r.failed
|
|
return 1 if total_failed else 0
|
|
|
|
|
|
def microblog(peer, r):
|
|
d = driver(peer)
|
|
a, b = state.provision(peer, accounts(peer))
|
|
r.check(a.token and b.token, "both accounts signed in")
|
|
try:
|
|
d.update_profile(a, a.account)
|
|
r.check(True, "profile updated (name, bio, fields, avatar)")
|
|
except Unsupported as e:
|
|
r.skip(str(e))
|
|
|
|
made = d.post(a, PostSpec(text=f"selftest post on {peer} (st1)", tags=["townselftest"]))
|
|
row = wait(lambda: d.stored([made.uri])[made.uri].exists and d.stored([made.uri])[made.uri])
|
|
r.check(row and row.exists, f"the post is in the database under its uri {made.uri}")
|
|
r.check(row and str(row.local_id) == str(made.local_id), f"database id {row and row.local_id} is the API id {made.local_id}")
|
|
r.check(row and row.visibility == "public", f"stored visibility public (got {row and row.visibility})")
|
|
|
|
d.like(b, made.uri)
|
|
r.check(wait(lambda: d.stored([made.uri])[made.uri].likes == 1), "a like counts once")
|
|
try:
|
|
d.boost(b, made.uri)
|
|
r.check(wait(lambda: d.stored([made.uri])[made.uri].boosts == 1), "a boost counts once")
|
|
except Unsupported as e:
|
|
r.skip(str(e))
|
|
|
|
reply = d.post(b, PostSpec(text=f"selftest reply on {peer} (st2)", reply_to_uri=made.uri))
|
|
rrow = wait(lambda: d.stored([reply.uri])[reply.uri].exists and d.stored([reply.uri])[reply.uri])
|
|
r.check(rrow and rrow.parent_uri == made.uri, f"the reply names its parent (got {rrow and rrow.parent_uri})")
|
|
r.check(wait(lambda: (d.stored([made.uri])[made.uri].replies or 0) >= 1), "the parent counts the reply")
|
|
|
|
poll = d.post(a, PostSpec(text=f"selftest poll on {peer} (st3)", poll={"options": ["tea", "coffee"], "expires_in": 86400}))
|
|
d.vote(b, poll.uri, [1])
|
|
r.check(wait(lambda: (d.stored([poll.uri])[poll.uri].votes or [None, None])[1] == 1),
|
|
f"a vote counts on the second option (got {d.stored([poll.uri])[poll.uri].votes})")
|
|
|
|
private = d.post(a, PostSpec(text=f"selftest followers-only on {peer} (st4)", visibility="followers"))
|
|
prow = d.stored([private.uri])[private.uri]
|
|
r.check(prow.visibility == "followers", f"stored visibility followers (got {prow.visibility})")
|
|
seen = d.seen(b, [made.uri, private.uri])
|
|
r.check(seen[made.uri], "a non-follower sees the public post")
|
|
r.check(not seen[private.uri], "a non-follower does not see the followers-only post")
|
|
|
|
try:
|
|
d.edit(a, made.uri, PostSpec(text=f"selftest post on {peer}, edited (st1)"))
|
|
r.check(wait(lambda: d.stored([made.uri])[made.uri].edited), "the edit is recorded")
|
|
except Unsupported as e:
|
|
r.skip(str(e))
|
|
|
|
d.delete(b, reply.uri)
|
|
gone = wait(lambda: (lambda x: not x.exists or x.deleted)(d.stored([reply.uri])[reply.uri]))
|
|
r.check(gone, "a deleted reply is gone or tombstoned")
|
|
|
|
try:
|
|
d.react(b, made.uri, "🎉")
|
|
r.check(wait(lambda: (d.stored([made.uri])[made.uri].reactions or {}).get("🎉", 0) >= 1), "a reaction is stored")
|
|
except Unsupported as e:
|
|
r.skip(str(e))
|
|
|
|
|
|
def lemmy(r):
|
|
d = driver("lemmy")
|
|
a, b = state.provision("lemmy", accounts("lemmy"))
|
|
r.check(a.token and b.token, "both accounts signed in")
|
|
name = f"stc{int(time.time())}"
|
|
c = d.community(a, name, "Selftest community")
|
|
thread = d.post(a, PostSpec(text="selftest thread body (st1)", title="Selftest thread", group=c["id"]))
|
|
row = d.stored([thread.uri])[thread.uri]
|
|
r.check(row.exists and row.local_id == thread.local_id, f"the thread is in the database ({row.local_id})")
|
|
d.like(b, thread.uri)
|
|
r.check(wait(lambda: d.stored([thread.uri])[thread.uri].likes >= 2), "an upvote counts (with the author's own)")
|
|
comment = d.post(b, PostSpec(text="selftest comment (st2)", reply_to_uri=thread.uri))
|
|
crow = d.stored([comment.uri])[comment.uri]
|
|
r.check(crow.parent_uri == thread.uri, f"the comment names its thread (got {crow.parent_uri})")
|
|
reply = d.post(a, PostSpec(text="selftest comment reply (st3)", reply_to_uri=comment.uri))
|
|
rrow = d.stored([reply.uri])[reply.uri]
|
|
r.check(rrow.parent_uri == comment.uri, f"a nested comment names its parent comment (got {rrow.parent_uri})")
|
|
d.downvote(a, comment.uri)
|
|
r.check(wait(lambda: (d.stored([comment.uri])[comment.uri].reactions or {}).get("down") == 1), "a downvote counts")
|
|
d.edit(a, thread.uri, PostSpec(text="selftest thread body, edited (st1)"))
|
|
r.check(wait(lambda: d.stored([thread.uri])[thread.uri].edited), "the edit is recorded")
|
|
pm = d.post(a, PostSpec(text="selftest private message (st4)", visibility="direct",
|
|
mentions=[f"{b.account.username}@lemmy.test"]))
|
|
r.check(d.stored([pm.uri])[pm.uri].exists, "a private message is in the database")
|
|
d.delete(a, reply.uri)
|
|
r.check(wait(lambda: d.stored([reply.uri])[reply.uri].deleted), "a deleted comment is marked deleted")
|