Files
yttrx-welcomebot/test_local.py
T
pmb 8966de2a1d
docker-build-push / build-push (push) Successful in 4s
Add text format, CORS, and an importer so other instances can use the list
Mastodon has no RBL/DNSBL support — nothing in it consults an outside
list when deciding whether to accept a signup, confirmed against v4.7.1
(the only outbound lookup in that path is EmailMxValidator's MX
resolution, which feeds the local email_domain_blocks table). So a
shared blocklist has to be imported rather than queried live, the same
way Garden Fence and other community lists propagate. These are the
pieces that make ours importable.

- ?format=txt on both per-list endpoints: one entry per line under a '#'
  comment header, the form blocklists are traded in and the form a cron
  job can consume without a JSON parser. The combined /api/blocks stays
  JSON-only — a text rendering would mix CIDRs with domains in one file,
  defeating the point. An unrecognised ?format is a 400 rather than a
  silent fallback to JSON, and the enabled-gate still runs first so a
  disabled deployment 404s either way.
- Access-Control-Allow-Origin: * on blocklist responses, so browser-side
  admin tooling can read them. The data is already public.
- bin/yttrx-blocklist-import: standard-library-only importer another
  admin can run against their own instance. Dry run unless --apply;
  422 from Mastodon counts as "already blocked", not an error.

The importer defaults IP entries to sign_up_requires_approval rather
than the sign_up_block yttrx uses itself. The entries are whole ASN
routes, which suits yttrx because a flagged signup here is nearly always
datacenter/VPN/proxy space — that calibration isn't transferable, so an
importer gets the reviewable severity by default and has to opt into the
hard one.
2026-09-13 00:59:12 -07:00

908 lines
42 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": "[email protected]"}},
})
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, cidr: ipblocks.append((ip, acct, org, cidr)) or True
# A flagged-but-not-held signup starts the suspicious watch inline, which
# would otherwise hit the network for a baseline snapshot — stub it.
main.fetch_account_counts = lambda account_id: (0, 0)
def classify(ip):
return classifications[ip]
classifications = {
"203.0.113.10": ("clean", "Example Residential ISP", False, "203.0.113.10/32"),
"198.51.100.20": ("datacenter", "Example Cloud Hosting Inc", True, "198.51.100.0/24"),
"198.51.100.21": ("datacenter", "Example Cloud Hosting Inc", True, "198.51.100.0/24"),
}
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", "198.51.100.0/24")], 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
# B2. datacenter IP, held, but Mastodon already auto-approved the signup
# (open registration, never queued) -> account.approved will never
# fire, so process_signup must welcome + start the watch immediately
# instead of waiting for an event that isn't coming.
sent.clear(); dms.clear(); ipblocks.clear()
classifications["198.51.100.22"] = ("datacenter", "Example Cloud Hosting Inc", True, "198.51.100.0/24")
main.process_signup("104", "dc3", "198.51.100.22", "", True)
assert ("104", "dc3") in sent, "already-approved flagged signup must be welcomed immediately"
assert main.get_signup_flag("104") is True
assert main.already_watched_suspicious("104"), "already-approved flagged signup must start its watch immediately"
# 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)) or True
# A flagged-but-not-held signup starts the suspicious watch inline, which
# would otherwise hit the network for a baseline snapshot — stub it.
main.fetch_account_counts = lambda account_id: (0, 0)
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", "", "[email protected]")
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", "", "[email protected]")
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", "", "[email protected]")
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,
"[email protected]")
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,
"[email protected]")
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,
"[email protected]")
assert ("998", "suspend") not in actions, actions
assert domain_blocks == [], domain_blocks
def range_cache_tests():
"""Drive the ipapi range-cache helpers (_ip_range_bounds/cache_ip_intel/
cached_ip_intel) directly — pure sqlite + ipaddress logic, no network.
This is what classify_signup_ip's block_cidr (the network register_
ip_block blocks) is built on."""
intel = {
"is_datacenter": True, "is_vpn": False, "is_proxy": False,
"is_tor": False, "is_abuser": False, "org": "Example Cloud Hosting Inc",
}
# A. a valid route containing the ip caches (and returns) the wider CIDR,
# not just the single address.
main.cache_ip_intel("198.51.100.77", intel, route="198.51.100.0/24")
cached = main.cached_ip_intel("198.51.100.77")
assert cached["cidr"] == "198.51.100.0/24", cached
assert cached["is_datacenter"] is True
# B. any other address in that same cached range hits the cache with the
# same wider CIDR — this is the point of range-based caching, and
# exactly what lets a repeat signup from elsewhere in the block also
# resolve to blocking the whole range.
cached2 = main.cached_ip_intel("198.51.100.200")
assert cached2["cidr"] == "198.51.100.0/24", cached2
# C. an address outside the cached range is a cache miss.
assert main.cached_ip_intel("198.51.101.1") is None
# D. a route that doesn't actually contain the ip falls back to a /32 —
# never trust a route wide enough to not even cover the IP it came from.
main.cache_ip_intel("203.0.113.9", intel, route="10.0.0.0/8")
cached3 = main.cached_ip_intel("203.0.113.9")
assert cached3["cidr"] == "203.0.113.9/32", cached3
# E. no route at all -> /32.
main.cache_ip_intel("203.0.113.10", intel, route="")
cached4 = main.cached_ip_intel("203.0.113.10")
assert cached4["cidr"] == "203.0.113.10/32", cached4
def combined_signal_tests():
"""Drive process_signup's combined IP+email immediate-suspend path
(SUSPICIOUS_COMBINED_*), independent of the OR-based hourly sweep."""
sent = []
dms = []
actions = []
ipblocks = []
domain_blocks = []
main.send_welcome = lambda account_id, acct: sent.append((account_id, acct))
main.dm_moderator = lambda message: dms.append(message)
main.apply_action = lambda target_id, action, text: actions.append((target_id, action))
main.register_ip_block = lambda ip, acct, org, cidr: ipblocks.append((ip, acct, org, cidr)) or True
main.register_email_domain_block = lambda domain, acct: domain_blocks.append((domain, acct)) or True
main.fetch_account_counts = lambda account_id: (0, 0)
main.IP_SCRUTINY_ENABLED = True
main.IP_SCRUTINY_DRY_RUN = False
main.IP_SCRUTINY_HOLD_WELCOME = True
main.IP_SCRUTINY_AUTO_IPBLOCK = True
main.CHECK_MAIL_ENABLED = True
main.CHECK_MAIL_API_KEY = "test-key"
main.CHECK_MAIL_DRY_RUN = False
main.CHECK_MAIL_HOLD_WELCOME = True
main.CHECK_MAIL_AUTO_DOMAIN_BLOCK = True
main.SUSPICIOUS_COMBINED_ENABLED = True
main.SUSPICIOUS_COMBINED_ACTION = "suspend"
main.SUSPICIOUS_COMBINED_DRY_RUN = False
main.ABUSE_ALLOWLIST = {"trustedstaff"}
ip_classifications = {
"198.51.100.40": ("datacenter", "Example Cloud Hosting Inc", True, "198.51.100.32/28"),
"198.51.100.41": ("datacenter", "Example Cloud Hosting Inc", True, "198.51.100.32/28"),
}
main.classify_signup_ip = lambda ip: ip_classifications[ip]
email_classifications = {
"temp-mail.org": (True, 99),
"gmail.com": (False, 5),
}
main.classify_email_domain = lambda domain: email_classifications[domain]
# A. both signals flagged, live -> suspended immediately, no welcome, no
# suspicious-watch entry (already actioned, nothing left to sweep).
# The individual ip/email auto-blocks still fire independently.
main.process_signup("501", "bothbad", "198.51.100.40", "[email protected]")
assert ("501", "suspend") in actions, actions
assert ("501", "bothbad") not in sent, "must not welcome an immediately-suspended signup"
assert any("bothbad" in d and "BOTH" in d for d in dms), dms
with main._db() as conn:
row = conn.execute(
"SELECT 1 FROM suspicious_watch WHERE account_id = ?", ("501",),
).fetchone()
assert row is None, "an immediately-suspended signup must not also enter the hourly watch"
assert ("198.51.100.40", "bothbad", "Example Cloud Hosting Inc", "198.51.100.32/28") in ipblocks, ipblocks
assert ("temp-mail.org", "bothbad") in domain_blocks, domain_blocks
# B. only IP flagged -> untouched by the combined path, falls through to
# the normal single-signal hold/welcome behavior.
actions.clear(); sent.clear(); dms.clear()
main.process_signup("502", "iponly", "198.51.100.41", "[email protected]")
assert ("502", "suspend") not in actions, actions
assert ("502", "iponly") not in sent, "single-signal flag should still be held"
# C. both flagged, but allowlisted acct -> combined path skipped; falls
# through to the normal (still-held) path like any other flagged signup.
actions.clear(); sent.clear(); dms.clear()
main.process_signup("503", "trustedstaff", "198.51.100.40", "[email protected]")
assert ("503", "suspend") not in actions, "allowlisted acct must not be auto-suspended"
# D. both flagged, SUSPICIOUS_COMBINED_DRY_RUN -> DM only, no real
# suspend; falls through to the normal held-welcome path.
actions.clear(); sent.clear(); dms.clear()
main.SUSPICIOUS_COMBINED_DRY_RUN = True
main.process_signup("504", "drybothbad", "198.51.100.40", "[email protected]")
assert ("504", "suspend") not in actions, "dry-run must not call apply_action"
assert any("[DRY-RUN]" in d and "drybothbad" in d for d in dms), dms
assert ("504", "drybothbad") not in sent, "still held by the individual signal holds"
# E. both flagged, live, but apply_action fails -> falls back to the
# normal held-welcome path instead of silently dropping the signup.
actions.clear(); sent.clear(); dms.clear()
main.SUSPICIOUS_COMBINED_DRY_RUN = False
def boom(target_id, action, text):
raise main.httpx.HTTPError("boom")
main.apply_action = boom
main.process_signup("505", "failsuspend", "198.51.100.40", "[email protected]")
assert ("505", "failsuspend") not in sent, "still held, falls back to the normal flagged path"
def strong_ip_suspend_tests():
"""Drive process_signup's strong-IP-signal immediate-suspend path
(IP_SCRUTINY_STRONG_SUSPEND_*): datacenter/proxy/tor/abuser suspend
immediately regardless of the email signal, but vpn alone does not — it
still falls through to the normal hold/welcome path (or the existing
combined-signal suspend, if also paired with a flagged email domain)."""
sent = []
dms = []
actions = []
ipblocks = []
main.send_welcome = lambda account_id, acct: sent.append((account_id, acct))
main.dm_moderator = lambda message: dms.append(message)
main.apply_action = lambda target_id, action, text: actions.append((target_id, action))
main.register_ip_block = lambda ip, acct, org, cidr: ipblocks.append((ip, acct, org, cidr)) or True
main.register_email_domain_block = lambda domain, acct: True
main.fetch_account_counts = lambda account_id: (0, 0)
main.IP_SCRUTINY_ENABLED = True
main.IP_SCRUTINY_DRY_RUN = False
main.IP_SCRUTINY_HOLD_WELCOME = True
main.IP_SCRUTINY_AUTO_IPBLOCK = True
main.CHECK_MAIL_ENABLED = True
main.CHECK_MAIL_API_KEY = "test-key"
main.CHECK_MAIL_DRY_RUN = False
main.CHECK_MAIL_HOLD_WELCOME = True
main.CHECK_MAIL_AUTO_DOMAIN_BLOCK = True
main.SUSPICIOUS_COMBINED_ENABLED = True
main.SUSPICIOUS_COMBINED_ACTION = "suspend"
main.SUSPICIOUS_COMBINED_DRY_RUN = False
main.ABUSE_ALLOWLIST = {"trustedstaff"}
main.IP_SCRUTINY_STRONG_SUSPEND_ENABLED = True
main.IP_SCRUTINY_STRONG_SUSPEND_ACTION = "suspend"
main.IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN = False
ip_classifications = {
"198.51.100.60": ("datacenter", "Example Cloud Hosting Inc", True, "198.51.100.48/28"),
"198.51.100.61": ("proxy", "Example Proxy Networks", True, "198.51.100.48/28"),
"198.51.100.62": ("vpn", "Example VPN Provider", True, "198.51.100.48/28"),
"198.51.100.63": ("vpn", "Example VPN Provider", True, "198.51.100.48/28"),
}
main.classify_signup_ip = lambda ip: ip_classifications[ip]
email_classifications = {
"temp-mail.org": (True, 99),
"gmail.com": (False, 5),
}
main.classify_email_domain = lambda domain: email_classifications[domain]
# A. datacenter (strong), clean email -> suspended immediately regardless
# of the (non-flagged) email signal; ip_block still registered.
main.process_signup("601", "strongdc", "198.51.100.60", "[email protected]")
assert ("601", "suspend") in actions, actions
assert ("601", "strongdc") not in sent, "must not welcome an immediately-suspended signup"
assert any("strongdc" in d and "strong IP signal" in d for d in dms), dms
assert ("198.51.100.60", "strongdc", "Example Cloud Hosting Inc", "198.51.100.48/28") in ipblocks, ipblocks
# B. proxy (strong), no email at all -> still suspended immediately.
actions.clear(); sent.clear(); dms.clear()
main.process_signup("602", "strongproxy", "198.51.100.61")
assert ("602", "suspend") in actions, actions
assert ("602", "strongproxy") not in sent
# C. vpn only (not strong), clean email -> NOT suspended, falls through to
# the normal held-welcome path.
actions.clear(); sent.clear(); dms.clear()
main.process_signup("603", "vpnonly", "198.51.100.62", "[email protected]")
assert ("603", "suspend") not in actions, actions
assert ("603", "vpnonly") not in sent, "still held by the individual IP-scrutiny hold"
# D. vpn (not strong) + flagged email -> not caught by the strong path,
# but still caught by the existing combined-signal suspend.
actions.clear(); sent.clear(); dms.clear()
main.process_signup("604", "vpnplusemail", "198.51.100.63", "[email protected]")
assert ("604", "suspend") in actions, actions
assert ("604", "vpnplusemail") not in sent
assert any("vpnplusemail" in d and "BOTH" in d for d in dms), dms
# E. allowlisted acct with a strong signal -> not suspended.
actions.clear(); sent.clear(); dms.clear()
main.process_signup("605", "trustedstaff", "198.51.100.60", "[email protected]")
assert ("605", "suspend") not in actions, "allowlisted acct must not be auto-suspended"
# F. strong signal, but IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN -> DM only, no
# real suspend; falls through to the normal held-welcome path.
actions.clear(); sent.clear(); dms.clear()
main.IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN = True
main.process_signup("606", "drystrong", "198.51.100.60", "[email protected]")
assert ("606", "suspend") not in actions, "dry-run must not call apply_action"
assert any("[DRY-RUN]" in d and "drystrong" in d for d in dms), dms
assert ("606", "drystrong") not in sent, "still held by the individual IP-scrutiny hold"
# G. strong signal, live, but apply_action fails -> falls back to the
# normal held-welcome path instead of silently dropping the signup.
actions.clear(); sent.clear(); dms.clear()
main.IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN = False
def boom(target_id, action, text):
raise main.httpx.HTTPError("boom")
main.apply_action = boom
main.process_signup("607", "failstrong", "198.51.100.60", "[email protected]")
assert ("607", "failstrong") not in sent, "still held, falls back to the normal flagged path"
def suspicious_watch_tests():
"""Drive maybe_start_suspicious_watch: baseline capture on flagged
signups only, never on clean ones, and never twice for the same account."""
main.SUSPICIOUS_SWEEP_ENABLED = True
counts = {"301": (2, 5), "302": (0, 0)}
main.fetch_account_counts = lambda account_id: counts[account_id]
# A. flagged by IP only -> watch started with that baseline.
main.record_signup_ip("301", "watched1", "198.51.100.30", "datacenter", "Cloud Inc", True)
main.maybe_start_suspicious_watch("301", "watched1")
with main._db() as conn:
row = conn.execute(
"SELECT reasons, baseline_statuses_count, baseline_following_count, status "
"FROM suspicious_watch WHERE account_id = ?", ("301",),
).fetchone()
assert row == ("ip", 2, 5, "pending"), row
# B. calling again (e.g. account.created then account.approved both
# firing) does not reset/duplicate the baseline.
counts["301"] = (99, 99)
main.maybe_start_suspicious_watch("301", "watched1")
with main._db() as conn:
row = conn.execute(
"SELECT baseline_statuses_count FROM suspicious_watch WHERE account_id = ?", ("301",),
).fetchone()
assert row == (2,), "re-calling must not overwrite the original baseline"
# C. never flagged -> no watch row at all.
main.maybe_start_suspicious_watch("302", "clean3")
with main._db() as conn:
row = conn.execute(
"SELECT 1 FROM suspicious_watch WHERE account_id = ?", ("302",),
).fetchone()
assert row is None, "an unflagged signup must never be watched"
def suspicious_sweep_tests():
"""Drive app.suspicious_sweep.sweep() directly against rows seeded into
the shared sqlite store, with the Mastodon admin-account fetch and
apply_action/dm_moderator stubbed out (no network)."""
import app.suspicious_sweep as sweep_mod
sweep_mod.SUSPICIOUS_SWEEP_ENABLED = True
sweep_mod.SUSPICIOUS_GRACE_HOURS = 24
sweep_mod.SUSPICIOUS_ACTION = "suspend"
sweep_mod.SUSPICIOUS_DRY_RUN = False
sweep_mod.ABUSE_SKIP_PRIVILEGED = True
sweep_mod.ABUSE_ALLOWLIST = {"trustedstaff"}
def seed(account_id, acct, reasons, baseline_statuses, baseline_following, hours_ago):
started_at = (datetime.now(timezone.utc) - timedelta(hours=hours_ago)).isoformat()
with main._db() as conn:
conn.execute(
"INSERT INTO suspicious_watch "
"(account_id, acct, reasons, started_at, baseline_statuses_count, "
" baseline_following_count) VALUES (?, ?, ?, ?, ?, ?)",
(account_id, acct, reasons, started_at, baseline_statuses, baseline_following),
)
def status_of(account_id):
with main._db() as conn:
row = conn.execute(
"SELECT status FROM suspicious_watch WHERE account_id = ?", (account_id,),
).fetchone()
return row[0] if row else None
admin_accounts = {}
sweep_mod._fetch_admin_account = lambda account_id: admin_accounts[account_id]
actions = []
dms = []
sweep_mod.apply_action = lambda target_id, action, text: actions.append((target_id, action))
sweep_mod.dm_moderator = lambda message: dms.append(message)
def public(statuses_count=0, following_count=0, **extra):
acct = {"statuses_count": statuses_count, "following_count": following_count}
return {"account": acct, **extra}
# A. not yet due (started 1h ago, 24h grace) -> untouched, still pending.
seed("401", "toosoon", "ip", 0, 0, hours_ago=1)
admin_accounts["401"] = public(0, 0)
# B. due, zero new posts/follows since baseline -> suspended.
seed("402", "dormantbot", "ip", 0, 0, hours_ago=25)
admin_accounts["402"] = public(0, 0)
# C. due, but posted since baseline -> cleared, not suspended.
seed("403", "postedsince", "email", 3, 1, hours_ago=25)
admin_accounts["403"] = public(4, 1)
# D. due, but followed someone since baseline -> cleared, not suspended.
seed("404", "followedsince", "ip,email", 0, 0, hours_ago=25)
admin_accounts["404"] = public(0, 1)
# E. due, but already suspended/silenced by another mechanism -> skipped.
seed("405", "alreadylimited", "ip", 0, 0, hours_ago=25)
admin_accounts["405"] = public(0, 0, suspended=True)
# F. due, but holds a staff role -> skipped, never suspended.
seed("406", "staffer", "ip", 0, 0, hours_ago=25)
admin_accounts["406"] = public(0, 0, role={"id": 3, "name": "Owner"})
# G. due, allowlisted by handle -> skipped.
seed("407", "trustedstaff", "ip", 0, 0, hours_ago=25)
admin_accounts["407"] = public(0, 0)
sweep_mod.sweep()
assert status_of("401") == "pending", "not-yet-due watch must be left alone"
assert ("402", "suspend") in actions, actions
assert status_of("402") == "suspend"
assert any("dormantbot" in d for d in dms), dms
assert ("403", "suspend") not in actions, actions
assert status_of("403") == "cleared"
assert ("404", "suspend") not in actions, actions
assert status_of("404") == "cleared"
assert ("405", "suspend") not in actions, actions
assert status_of("405") == "skipped-already-limited"
assert ("406", "suspend") not in actions, actions
assert status_of("406") == "skipped-privileged"
assert ("407", "suspend") not in actions, actions
assert status_of("407") == "skipped-privileged"
# H. re-running the sweep must not re-suspend or re-DM an already-resolved row.
before_actions, before_dms = len(actions), len(dms)
sweep_mod.sweep()
assert len(actions) == before_actions, "resolved rows must not be re-actioned"
assert len(dms) == before_dms, "resolved rows must not be re-DMed"
# I. dry-run mode logs/DMs but takes no real action.
seed("408", "dryrunbot", "ip", 0, 0, hours_ago=25)
admin_accounts["408"] = public(0, 0)
sweep_mod.SUSPICIOUS_DRY_RUN = True
sweep_mod.sweep()
assert ("408", "suspend") not in actions, "dry-run must not call apply_action"
assert status_of("408") == "dry-run:suspend"
assert any("[DRY-RUN]" in d and "dryrunbot" in d for d in dms), dms
def blocks_api_tests():
"""Drive the read-only blocklist API: the BLOCKS_API_ENABLED gate, the
response shape, the deliberate omission of provider classification data
and account identifiers, and the pre-block_cidr fallback."""
# Seed deterministic rows rather than relying on what earlier tests left
# in the shared db; assertions below check for presence, not exact lists.
main.record_signup_ip("900", "blockedone", "198.18.0.90", "datacenter",
"Cloud Inc", True, "198.18.0.0/24")
main.mark_ipblock_registered("900")
# A second signup out of the same range collapses into one entry.
main.record_signup_ip("901", "blockedtwo", "198.18.0.91", "datacenter",
"Cloud Inc", True, "198.18.0.0/24")
main.mark_ipblock_registered("901")
# Classified but never blocked -> must not appear at all.
main.record_signup_ip("902", "notblockedacct", "203.0.113.199", "clean", "", False)
# A row predating the block_cidr column (NULL) -> falls back to the /32
# that was in fact registered for it back then.
main.record_signup_ip("903", "legacyacct", "192.0.2.77", "datacenter", "Old Corp", True)
main.mark_ipblock_registered("903")
main.record_signup_email("910", "spamone", "blocked-example.test", True, 95, True)
main.mark_email_domain_blocked("910")
main.record_signup_email("911", "spamtwo", "blocked-example.test", True, 95, True)
main.mark_email_domain_blocked("911")
main.record_signup_email("912", "cleanacct", "notblocked-example.test", False, 0, False)
paths = ("/api/blocks", "/api/blocks/ip-ranges", "/api/blocks/email-domains")
# A. Disabled (the shipped default) -> 404, indistinguishable from a path
# that doesn't exist.
main.BLOCKS_API_ENABLED = False
for path in paths:
assert client.get(path).status_code == 404, path
# B. Enabled -> 200, with the ranges and domains actually blocked.
main.BLOCKS_API_ENABLED = True
r = client.get("/api/blocks")
assert r.status_code == 200, r.status_code
assert r.headers["cache-control"] == f"public, max-age={main.BLOCKS_API_CACHE_SECONDS}"
body = r.json()
ranges = {e["cidr"]: e for e in body["ip_ranges"]["items"]}
domains = {e["domain"]: e for e in body["email_domains"]["items"]}
assert body["ip_ranges"]["count"] == len(body["ip_ranges"]["items"])
assert body["email_domains"]["count"] == len(body["email_domains"]["items"])
assert "198.18.0.0/24" in ranges, ranges
assert ranges["198.18.0.0/24"]["signups"] == 2, "repeat signups collapse into one entry"
assert "192.0.2.77/32" in ranges, "a NULL block_cidr row falls back to its /32"
assert "blocked-example.test" in domains, domains
assert domains["blocked-example.test"]["signups"] == 2
assert "notblocked-example.test" not in domains, "an unblocked domain must not be listed"
# C. Nothing that identifies an account, and nothing sourced from the
# ipapi.is / check-mail.org feeds, may appear in the response.
raw = r.text
for leak in ("blockedone", "legacyacct", "notblockedacct", "spamone", "cleanacct",
"203.0.113.199", "datacenter", "Cloud Inc", "Old Corp"):
assert leak not in raw, f"{leak!r} must not be exposed by the blocklist API"
for entry in body["ip_ranges"]["items"]:
assert set(entry) == {"cidr", "signups", "first_blocked_at", "last_blocked_at"}, entry
for entry in body["email_domains"]["items"]:
assert set(entry) == {"domain", "signups", "first_blocked_at", "last_blocked_at"}, entry
# D. The per-list endpoints agree with the combined document.
ips_only = client.get("/api/blocks/ip-ranges").json()
domains_only = client.get("/api/blocks/email-domains").json()
assert ips_only["items"] == body["ip_ranges"]["items"]
assert domains_only["items"] == body["email_domains"]["items"]
# E. ?limit truncates; a nonsensical limit is treated as "no limit".
assert len(client.get("/api/blocks/ip-ranges?limit=1").json()["items"]) == 1
assert (client.get("/api/blocks/ip-ranges?limit=-5").json()["items"]
== ips_only["items"]), "a negative limit must not empty the list"
# F. BLOCKS_API_MAX_ITEMS caps the caller's limit, not the other way round.
main.BLOCKS_API_MAX_ITEMS = 1
assert len(client.get("/api/blocks/ip-ranges?limit=999").json()["items"]) == 1
main.BLOCKS_API_MAX_ITEMS = 5000
# G. CORS is open — other instances' tooling may fetch this from a browser.
assert r.headers["access-control-allow-origin"] == "*"
# H. ?format=txt renders one entry per line under a comment header, so it
# pipes into another admin's tooling without a JSON parser.
t = client.get("/api/blocks/ip-ranges?format=txt")
assert t.status_code == 200, t.status_code
assert t.headers["content-type"].startswith("text/plain"), t.headers["content-type"]
assert t.headers["access-control-allow-origin"] == "*"
lines = t.text.strip().split("\n")
header = [ln for ln in lines if ln.startswith("#")]
payload = [ln for ln in lines if not ln.startswith("#")]
assert any("source:" in ln for ln in header), header
assert f"entries: {len(ips_only['items'])}" in "\n".join(header)
assert payload == [e["cidr"] for e in ips_only["items"]], payload[:3]
td = client.get("/api/blocks/email-domains?format=txt")
assert [ln for ln in td.text.strip().split("\n") if not ln.startswith("#")] \
== [e["domain"] for e in domains_only["items"]]
# I. An unknown format is rejected rather than quietly serving JSON, and
# the combined endpoint refuses text (it would mix CIDRs with domains).
assert client.get("/api/blocks/ip-ranges?format=yaml").status_code == 400
assert client.get("/api/blocks?format=txt").status_code == 400
assert client.get("/api/blocks?format=json").status_code == 200
# J. The format gate never leaks existence when the API is switched off.
main.BLOCKS_API_ENABLED = False
assert client.get("/api/blocks/ip-ranges?format=txt").status_code == 404
assert client.get("/api/blocks?format=txt").status_code == 404
main.BLOCKS_API_ENABLED = True
main.BLOCKS_API_MAX_ITEMS = 5000
if __name__ == "__main__":
routing_tests()
policy_tests()
ip_scrutiny_tests()
email_scrutiny_tests()
range_cache_tests()
combined_signal_tests()
strong_ip_suspend_tests()
suspicious_watch_tests()
suspicious_sweep_tests()
blocks_api_tests()
print("ALL TESTS PASSED")