Initial commit: welcome-bot, abuse-bot, dormant-sweep, IP signup scrutiny

This commit is contained in:
2026-07-02 16:22:30 -07:00
commit c0e5df3cc4
17 changed files with 2231 additions and 0 deletions
+300
View File
@@ -0,0 +1,300 @@
"""Local smoke test: signature verification, payload routing, abuse policy.
Run after `pip install -r requirements.txt`:
WEBHOOK_SECRET=testsecret BOT_ACCESS_TOKEN=x python test_local.py
It uses FastAPI's TestClient and monkeypatches every outbound Mastodon call,
so it makes no network requests.
"""
import hashlib
import hmac
import json
import os
from datetime import datetime, timedelta, timezone
os.environ.setdefault("WEBHOOK_SECRET", "testsecret")
os.environ.setdefault("BOT_ACCESS_TOKEN", "dummy")
os.environ.setdefault("ABUSE_BOT_TOKEN", "dummy-admin")
os.environ.setdefault("ABUSE_DRY_RUN", "false")
os.environ.setdefault("MOD_ALERT_ACCT", "pete")
os.environ.setdefault("DB_PATH", "/tmp/welcomebot-test.db")
if os.path.exists(os.environ["DB_PATH"]):
os.remove(os.environ["DB_PATH"])
from fastapi.testclient import TestClient # noqa: E402
import app.main as main # noqa: E402
# Capture welcome calls instead of hitting the network.
sent = []
main.send_welcome = lambda account_id, acct: sent.append((account_id, acct))
# Capture report handling at the route boundary for the routing test.
reports = []
_real_handle_report = main.handle_report
main.handle_report = lambda *a: reports.append(a)
client = TestClient(main.app)
def sign(body: bytes) -> str:
return "sha256=" + hmac.new(b"testsecret", body, hashlib.sha256).hexdigest()
def post(payload, secret_ok=True):
body = json.dumps(payload).encode()
sig = sign(body) if secret_ok else "sha256=deadbeef"
return client.post(
"/webhook", content=body,
headers={"X-Hub-Signature": sig, "Content-Type": "application/json"},
)
def days_ago(n):
return (datetime.now(timezone.utc) - timedelta(days=n)).isoformat()
def routing_tests():
# 1. health
assert client.get("/healthz").json() == {"ok": True}
# 2. bad signature rejected
r = post({"event": "account.created", "object": {}}, secret_ok=False)
assert r.status_code == 401, r.status_code
# 3. missing signature rejected
body = json.dumps({"event": "account.created"}).encode()
assert client.post("/webhook", content=body).status_code == 401
# 4. valid account.created -> queued
payload = {
"event": "account.created",
"object": {
"id": "1001", "username": "alice", "domain": None,
"account": {"id": "1001", "username": "alice", "acct": "alice",
"url": "https://yttrx.com/@alice"},
},
}
r = post(payload)
assert r.status_code == 200, r.text
assert r.json().get("queued") == "alice", r.json()
assert sent == [("1001", "alice")], sent
# 5. report.created -> routed to handle_report with extracted fields
r = post({
"event": "report.created",
"object": {
"id": "55", "category": "spam",
"target_account": {
"id": "777", "username": "spammer", "domain": None,
"suspended": False, "silenced": False,
"account": {"id": "777", "acct": "spammer"},
},
},
})
assert r.status_code == 200, r.text
assert r.json().get("report") == "55", r.json()
# (report_id, target_id, acct, is_local, suspended, silenced, privileged)
assert reports == [("55", "777", "spammer", True, False, False, False)], reports
# 5b. report against a staff account -> privileged=True extracted from role
r = post({
"event": "report.created",
"object": {
"id": "56",
"target_account": {
"id": "778", "username": "waffles", "domain": None,
"suspended": False, "silenced": False,
"role": {"id": 3, "name": "Owner", "permissions": "1"},
"account": {"id": "778", "acct": "waffles"},
},
},
})
assert reports[-1] == ("56", "778", "waffles", True, False, False, True), reports[-1]
# 6. account.approved also welcomes — but dedup means alice (id 1001,
# already welcomed via account.created above) is not welcomed twice.
r = post({
"event": "account.approved",
"object": {"id": "1001", "username": "alice", "domain": None,
"account": {"id": "1001", "acct": "alice"}},
})
assert r.status_code == 200, r.text
assert r.json().get("queued") == "alice", r.json()
# send_welcome is stubbed to append; real dedup is exercised in policy run.
# A brand-new account via account.approved should also route to welcome.
r = post({
"event": "account.approved",
"object": {"id": "2002", "username": "carol", "domain": None,
"account": {"id": "2002", "acct": "carol"}},
})
assert r.json().get("queued") == "carol", r.json()
assert ("2002", "carol") in sent, sent
# 7. truly unknown event ignored
r = post({"event": "status.updated", "object": {"id": "5"}})
assert r.json().get("ignored") == "status.updated", r.json()
# 8. remote account skipped when LOCAL_ONLY (welcome)
r = post({
"event": "account.created",
"object": {"id": "9", "username": "bob", "domain": "other.social",
"account": {"id": "9", "acct": "bob@other.social"}},
})
assert r.json().get("skipped"), r.json()
def policy_tests():
"""Drive handle_report directly with stubbed Mastodon calls."""
main.handle_report = _real_handle_report
actions, dms, user_dms = [], [], []
main.apply_action = lambda target_id, action, text: actions.append((target_id, action))
main.dm_moderator = lambda message: dms.append(message)
main.dm_silenced_user = lambda acct: user_dms.append(acct)
def run(target_id, acct, *, profile, sources, **kw):
main.classify_account = lambda tid: profile
main.count_distinct_reporters = lambda tid: sources
defaults = dict(is_local=True, suspended=False, silenced=False, privileged=False)
defaults.update(kw)
main.handle_report("R", target_id, acct, defaults["is_local"],
defaults["suspended"], defaults["silenced"],
defaults["privileged"])
young = {"has_posts": True, "young": True, "dormant": False,
"newest_at": None, "oldest_seen": None, "truncated": False}
active = {"has_posts": True, "young": False, "dormant": False,
"newest_at": None, "oldest_seen": None, "truncated": False}
dormant = {"has_posts": True, "young": False, "dormant": True,
"newest_at": None, "oldest_seen": None, "truncated": False}
noposts = {"has_posts": False, "young": False, "dormant": False,
"newest_at": None, "oldest_seen": None, "truncated": False}
# young, 1 source -> below threshold (needs 2), no action
run("1", "y1", profile=young, sources=1)
assert actions == [], actions
# young, 2 sources -> silenced; both the mod and the user are DMed
run("2", "y2", profile=young, sources=2)
assert ("2", "silence") in actions, actions
assert any("y2" in d for d in dms), dms
assert "y2" in user_dms, user_dms
# dormant, 2 sources -> silenced
run("3", "d1", profile=dormant, sources=2)
assert ("3", "silence") in actions, actions
# active, 2 sources -> below threshold (needs 3), no action
run("4", "a1", profile=active, sources=2)
assert ("4", "silence") not in actions, actions
# active, 3 sources -> silenced
run("5", "a2", profile=active, sources=3)
assert ("5", "silence") in actions, actions
# no-posts, 2 sources -> silenced (grouped with young/dormant)
run("6", "n1", profile=noposts, sources=2)
assert ("6", "silence") in actions, actions
# already silenced -> skipped even with enough sources
run("7", "s1", profile=young, sources=5, silenced=True)
assert ("7", "silence") not in actions, actions
# remote -> skipped (ABUSE_LOCAL_ONLY)
run("8", "r1@elsewhere", profile=young, sources=5, is_local=False)
assert ("8", "silence") not in actions, actions
# privileged (staff role) -> skipped even with plenty of sources
run("9", "waffles", profile=young, sources=9, privileged=True)
assert ("9", "silence") not in actions, actions
# dedup: re-running an already-actioned target takes no new action
before = len(actions)
run("2", "y2", profile=young, sources=9)
assert len(actions) == before, "expected dedup to suppress repeat action"
def ip_scrutiny_tests():
"""Drive process_signup + the abuse threshold override, network stubbed out."""
sent = []
dms = []
ipblocks = []
main.send_welcome = lambda account_id, acct: sent.append((account_id, acct))
main.dm_moderator = lambda message: dms.append(message)
main.register_ip_block = lambda ip, acct, org: ipblocks.append((ip, acct, org))
def classify(ip):
return classifications[ip]
classifications = {
"203.0.113.10": ("residential", "Example Residential ISP"),
"198.51.100.20": ("datacenter", "Example Cloud Hosting Inc"),
"198.51.100.21": ("datacenter", "Example Cloud Hosting Inc"),
}
main.classify_signup_ip = classify
main.IP_SCRUTINY_ENABLED = True
# A. residential IP -> welcomed immediately, no ip-block, no flag recorded.
main.IP_SCRUTINY_DRY_RUN = False
main.IP_SCRUTINY_HOLD_WELCOME = True
main.IP_SCRUTINY_AUTO_IPBLOCK = True
main.process_signup("101", "res1", "203.0.113.10")
assert ("101", "res1") in sent, sent
assert ipblocks == [], ipblocks
assert main.get_signup_flag("101") is False, "residential signup should not be flagged"
# B. datacenter IP, HOLD_WELCOME + AUTO_IPBLOCK, live (not dry-run) ->
# welcome held, ip-block registered, moderator DM'd; account.approved
# later releases the welcome.
sent.clear(); dms.clear(); ipblocks.clear()
main.process_signup("102", "dc1", "198.51.100.20")
assert ("102", "dc1") not in sent, "welcome should be held for a flagged signup"
assert ipblocks == [("198.51.100.20", "dc1", "Example Cloud Hosting Inc")], ipblocks
assert any("dc1" in d and "held" in d for d in dms), dms
assert main.get_signup_flag("102") is True
main.send_welcome("102", "dc1") # simulate account.approved's unconditional welcome
assert ("102", "dc1") in sent, sent
# C. same datacenter IP, but IP_SCRUTINY_DRY_RUN -> welcomed immediately
# (no hold), no ip-block write, DM carries the dry-run prefix.
sent.clear(); dms.clear(); ipblocks.clear()
main.IP_SCRUTINY_DRY_RUN = True
main.process_signup("103", "dc2", "198.51.100.21")
assert ("103", "dc2") in sent, "dry-run must not hold the welcome"
assert ipblocks == [], "dry-run must not write an ip_block"
assert any(d.startswith("[DRY-RUN]") for d in dms), dms
# D. abuse threshold override: a flagged account needs only
# IP_SCRUTINY_ABUSE_THRESHOLD (1) distinct reporter to silence, vs. the
# normal young-tier threshold of 2 for an unflagged account.
main.IP_SCRUTINY_DRY_RUN = False
main.dm_moderator = lambda message: dms.append(message)
main.handle_report = _real_handle_report
actions = []
main.apply_action = lambda target_id, action, text: actions.append((target_id, action))
main.dm_silenced_user = lambda acct: None
young = {"has_posts": True, "young": True, "dormant": False,
"newest_at": None, "oldest_seen": None, "truncated": False}
main.classify_account = lambda tid: young
main.count_distinct_reporters = lambda tid: 1
# "102" (dc1) was flagged above -> 1 reporter is enough.
main.handle_report("R2", "102", "dc1", True, False, False, False)
assert ("102", "silence") in actions, actions
# "101" (res1) was not flagged -> 1 reporter stays below the tier's usual
# threshold of 2.
main.handle_report("R3", "101", "res1", True, False, False, False)
assert ("101", "silence") not in actions, actions
if __name__ == "__main__":
routing_tests()
policy_tests()
ip_scrutiny_tests()
print("ALL TESTS PASSED")