Initial commit: welcome-bot, abuse-bot, dormant-sweep, IP signup scrutiny
This commit is contained in:
+300
@@ -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")
|
||||
Reference in New Issue
Block a user