656eec09c0
Classifies each signup's email domain (domain-only, GDPR-friendly) alongside the existing IP scrutiny signal, with matching held-welcome and auto email_domain_block behavior. A report against an account with a flagged domain suspends immediately (no reporter-count threshold) and blocks the domain, classified live from the report payload rather than any signup-time record so it also covers pre-existing accounts. Ships CHECK_MAIL_DRY_RUN=true by default, independent of ABUSE_DRY_RUN, so the new report-triggered suspend path stays inert until watched.
396 lines
16 KiB
Python
396 lines
16 KiB
Python
"""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, email)
|
|
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("[DRY-RUN]" in d 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
|
|
|
|
|
|
def email_scrutiny_tests():
|
|
"""Drive process_signup's email-domain path + the report-triggered
|
|
suspend+domain-block override, network stubbed out."""
|
|
sent = []
|
|
dms = []
|
|
domain_blocks = []
|
|
main.send_welcome = lambda account_id, acct: sent.append((account_id, acct))
|
|
main.dm_moderator = lambda message: dms.append(message)
|
|
main.register_email_domain_block = lambda domain, acct: domain_blocks.append((domain, acct))
|
|
|
|
classifications = {
|
|
"gmail.com": (False, 5),
|
|
"temp-mail.org": (True, 99),
|
|
"sketchy-disposable.example": (True, 95),
|
|
}
|
|
main.classify_email_domain = lambda domain: classifications[domain]
|
|
|
|
main.CHECK_MAIL_ENABLED = True
|
|
main.CHECK_MAIL_API_KEY = "test-key" # required for the signup-path gate
|
|
main.IP_SCRUTINY_ENABLED = False # isolate the email-only signal
|
|
|
|
# A. clean domain -> welcomed immediately, no domain-block, not flagged.
|
|
main.CHECK_MAIL_DRY_RUN = False
|
|
main.CHECK_MAIL_HOLD_WELCOME = True
|
|
main.CHECK_MAIL_AUTO_DOMAIN_BLOCK = True
|
|
main.process_signup("201", "clean1", "", "clean1@gmail.com")
|
|
assert ("201", "clean1") in sent, sent
|
|
assert domain_blocks == [], domain_blocks
|
|
assert main.get_signup_email("201") == ("gmail.com", False)
|
|
|
|
# B. disposable domain, HOLD_WELCOME + AUTO_DOMAIN_BLOCK, live -> welcome
|
|
# held, domain blocked, moderator DM'd; account.approved later releases
|
|
# the welcome.
|
|
sent.clear(); dms.clear(); domain_blocks.clear()
|
|
main.process_signup("202", "spam1", "", "spam1@temp-mail.org")
|
|
assert ("202", "spam1") not in sent, "welcome should be held for a flagged signup"
|
|
assert domain_blocks == [("temp-mail.org", "spam1")], domain_blocks
|
|
assert any("spam1" in d and "held" in d for d in dms), dms
|
|
assert main.get_signup_email("202") == ("temp-mail.org", True)
|
|
|
|
main.send_welcome("202", "spam1") # simulate account.approved's unconditional welcome
|
|
assert ("202", "spam1") in sent, sent
|
|
|
|
# C. same idea, but CHECK_MAIL_DRY_RUN -> welcomed immediately (no hold),
|
|
# no domain-block write, DM carries the dry-run marker.
|
|
sent.clear(); dms.clear(); domain_blocks.clear()
|
|
main.CHECK_MAIL_DRY_RUN = True
|
|
main.process_signup("203", "spam2", "", "spam2@sketchy-disposable.example")
|
|
assert ("203", "spam2") in sent, "dry-run must not hold the welcome"
|
|
assert domain_blocks == [], "dry-run must not write a domain block"
|
|
assert any("[DRY-RUN]" in d for d in dms), dms
|
|
|
|
# D0. CHECK_MAIL_DRY_RUN alone holds the report-triggered suspend even
|
|
# though ABUSE_DRY_RUN=False in this test env (set in the module
|
|
# preamble) — the two dry-run switches are independent; either one
|
|
# holding is enough to keep this brand-new action path inert.
|
|
actions = []
|
|
main.apply_action = lambda target_id, action, text: actions.append((target_id, action))
|
|
main.dm_silenced_user = lambda acct: None
|
|
domain_blocks.clear()
|
|
|
|
main.handle_report("R9", "997", "heldbydryrun", True, False, False, False,
|
|
"heldbydryrun@temp-mail.org")
|
|
assert ("997", "suspend") not in actions, actions
|
|
assert domain_blocks == [], "CHECK_MAIL_DRY_RUN alone must prevent the domain block too"
|
|
|
|
# D. report-triggered override: ANY report against an account with a
|
|
# high-risk email domain suspends immediately (no distinct-reporter
|
|
# threshold) and blocks the domain — classified LIVE from the report
|
|
# payload's target_account.email, independent of whatever (if
|
|
# anything) process_signup recorded at signup time. This account was
|
|
# never seen by process_signup at all.
|
|
main.CHECK_MAIL_DRY_RUN = False
|
|
actions.clear()
|
|
domain_blocks.clear()
|
|
|
|
main.handle_report("R10", "999", "neversignedup", True, False, False, False,
|
|
"neversignedup@temp-mail.org")
|
|
assert ("999", "suspend") in actions, actions
|
|
assert domain_blocks == [("temp-mail.org", "neversignedup")], domain_blocks
|
|
|
|
# E. report against an account with a clean email domain -> untouched by
|
|
# this path, falls through to the normal reporter-count/tier policy.
|
|
actions.clear(); domain_blocks.clear()
|
|
noposts = {"has_posts": False, "young": False, "dormant": False,
|
|
"newest_at": None, "oldest_seen": None, "truncated": False}
|
|
main.classify_account = lambda tid: noposts
|
|
main.count_distinct_reporters = lambda tid: 1
|
|
main.handle_report("R11", "998", "clean2", True, False, False, False,
|
|
"clean2@gmail.com")
|
|
assert ("998", "suspend") not in actions, actions
|
|
assert domain_blocks == [], domain_blocks
|
|
|
|
|
|
if __name__ == "__main__":
|
|
routing_tests()
|
|
policy_tests()
|
|
ip_scrutiny_tests()
|
|
email_scrutiny_tests()
|
|
print("ALL TESTS PASSED")
|