"""Forum moderation: auto-mod guards, moderator login/actions (key + CSRF), reports, pinned/locked/removed behaviour, and the weekly scheduled threads.""" import io import time import pytest import forum import forum_bot import web_app SEED = "abandon ability able about above absent absorb abstract absurd abuse access accident" KEY = "test-mod-key-123" @pytest.fixture(autouse=True) def board(tmp_path, monkeypatch): monkeypatch.setattr(forum, "_DB_PATH", str(tmp_path / "forum.db")) monkeypatch.setenv("MOONBITE_FORUM_MOD_KEY", KEY) # ---------------------------------------------------------------- auto-mod def test_seed_phrases_and_keys_are_refused(): with pytest.raises(ValueError, match="seed phrase"): forum.create_thread("help", "x", "my words are " + SEED) with pytest.raises(ValueError, match="seed phrase"): forum.create_thread("help", "x", "key 5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ") # ordinary prose full of common words is fine forum.create_thread("ok", "x", "I think the miner is able to find a block about once an hour on my laptop.") def test_link_spam_and_duplicates_are_refused(): with pytest.raises(ValueError, match="Too many links"): forum.create_thread("x", "x", " ".join(f"https://e{i}.com" for i in range(6))) forum.create_thread("Same title", "x", "same words") with pytest.raises(ValueError, match="already posted"): forum.create_thread("same TITLE", "y", "Same words") t = forum.create_thread("other", "x", "body") forum.add_reply(t["id"], "a", "+1") forum.add_reply(t["id"], "b", "+1") # short and from someone else: fine with pytest.raises(ValueError): forum.add_reply(t["id"], "a", "+1") # same person, same text spam = "Buy cheap followers now at the best price on the internet" forum.add_reply(t["id"], "c", spam) with pytest.raises(ValueError): forum.add_reply(t["id"], "d", spam) # long copy-paste from anyone # ---------------------------------------------------------------- flags def test_pinned_locked_removed(): t = forum.create_thread("rules", "x", "body") other = forum.create_thread("other", "x", "other body") forum.set_flag("t", t["id"], "pinned", True) assert [p["id"] for p in forum.pinned_threads()] == [t["id"]] assert t["id"] not in [x["id"] for x in forum.list_threads()["threads"]] forum.set_flag("t", other["id"], "locked", True) with pytest.raises(ValueError, match="locked"): forum.add_reply(other["id"], "y", "hello") forum.add_reply(other["id"], None, "moderator note", official=True) # mods can still reply r = forum.add_reply(t["id"], "y", "spam reply") forum.set_flag("r", r["id"], "removed", True) assert forum.get_thread(t["id"])["replies"][0]["removed"] forum.set_flag("t", other["id"], "removed", True) assert forum.get_thread(other["id"]) is None assert forum.get_thread(other["id"], include_removed=True) is not None with pytest.raises(ValueError): forum.set_flag("r", r["id"], "pinned", True) def test_reports_group_and_resolve(): t = forum.create_thread("x", "x", "body") assert forum.report("t", t["id"], "spam", "net1") assert not forum.report("t", t["id"], "scam", "net1") # one per network assert forum.report("t", t["id"], "scam", "net2") rep = forum.open_reports() assert rep[0]["count"] == 2 and rep[0]["reasons"] == ["scam", "spam"] forum.resolve_reports("t", t["id"]) assert forum.open_reports() == [] def test_metrics_count_people_not_the_official_account(): t = forum.create_thread("q", "alice", "question") forum.add_reply(t["id"], "bob", "answer") forum.create_thread("report", None, "numbers", "network", official=True, automated=True) m = forum.metrics() assert m["this_week"] == {"threads": 1, "replies": 1, "posters": 2} # ---------------------------------------------------------------- web def _client(): web_app.app.config["SESSION_COOKIE_SECURE"] = False return web_app.app.test_client() def _csrf(c): with c.session_transaction() as s: return s.get("forum_csrf") def test_mod_login_and_actions_need_key_and_csrf(): c = _client() t = forum.create_thread("x", "x", "body") # not logged in: action refused c.post("/discussions/mod/action", data={"kind": "t", "id": t["id"], "action": "pin", "csrf": "x"}) assert not forum.pinned_threads() assert b"Moderator login" in c.get("/discussions/mod").data c.post("/discussions/mod/login", data={"key": "wrong"}) assert b"Moderator login" in c.get("/discussions/mod").data c.post("/discussions/mod/login", data={"key": KEY}) assert b"Moderator desk" in c.get("/discussions/mod").data # logged in but no / wrong CSRF token: refused c.post("/discussions/mod/action", data={"kind": "t", "id": t["id"], "action": "pin", "csrf": "bad"}) assert not forum.pinned_threads() c.post("/discussions/mod/action", data={"kind": "t", "id": t["id"], "action": "pin", "csrf": _csrf(c), "next": "//evil.example"}) assert forum.pinned_threads() def test_only_moderators_can_post_as_verified(): c = _client() t = forum.create_thread("x", "x", "body") c.post(f"/discussions/{t['id']}/reply", data={"body": "trying to fake it", "as_official": "1"}) assert not forum.get_thread(t["id"])["replies"][0]["is_official"] c.post("/discussions/mod/login", data={"key": KEY}) c.post(f"/discussions/{t['id']}/reply", data={"body": "real mod reply", "as_official": "1", "csrf": _csrf(c)}) rep = forum.get_thread(t["id"])["replies"][1] assert rep["is_official"] and rep["author"] == forum.OFFICIAL_NAME def test_report_api_is_same_origin_json(): c = _client() t = forum.create_thread("x", "x", "body") assert c.post("/api/discussions/report", json={"kind": "t", "id": t["id"], "reason": "spam"}, headers={"Origin": "https://evil.example"}).status_code == 403 assert c.post("/api/discussions/report", json={"kind": "t", "id": t["id"], "reason": "spam"}).status_code == 200 assert forum.open_reports()[0]["id"] == t["id"] def test_mod_desk_off_without_key(monkeypatch): monkeypatch.delenv("MOONBITE_FORUM_MOD_KEY") assert _client().get("/discussions/mod").status_code == 404 # ---------------------------------------------------------------- weekly threads class Node: def getblockcount(self): return 100 def getblockhash(self, h): return f"h{h}" def call(self, method, *params): if method == "getblockheader": return {"time": int(time.time()) - (100 - int(params[0][1:])) * 120} if method == "getnetworkhashps": return 1600.0 raise AssertionError(method) def _day(weekday: int) -> int: """A UTC timestamp at 23:00 (after every posting slot) on the given weekday (Monday = 0).""" base = 1_800_000_000 - 1_800_000_000 % 86400 + 82_800 return base + ((weekday - time.gmtime(base).tm_wday) % 7) * 86400 def test_weekly_threads_post_on_their_day_once(): for wd, prefix in ((0, "Show your rig"), (3, "Hot take"), (5, "Predictions")): posted = forum_bot._weekly(Node(), _day(wd), None, False, io.StringIO()) assert len(posted) == 1 assert forum_bot._weekly(Node(), _day(wd), None, False, io.StringIO()) == [] assert any(t["title"].startswith(prefix) for t in forum.list_threads(limit=50)["threads"]) assert forum_bot._weekly(Node(), _day(1), None, False, io.StringIO()) == [] # Tuesday: nothing def test_predictions_get_real_numbers_next_week(): forum_bot._weekly(Node(), _day(5), None, False, io.StringIO()) first = forum_bot._thread_for(f"weekly:predict:{forum_bot._week(_day(5))}") forum_bot._weekly(Node(), _day(5) + 7 * 86400, None, False, io.StringIO()) replies = forum.get_thread(first)["replies"] assert len(replies) == 1 and replies[0]["is_official"] and "Chain height: 100" in replies[0]["body"] # ---------------------------------------------------------------- pacing def test_posts_wait_for_their_human_hours_slot(): day = _day(0) - 82_800 # 00:00 UTC on a Monday key = f"weekly:rig:{forum_bot._week(day)}" s = forum_bot.slot(key, *forum_bot.WEEKLY_WINDOW) assert 9 * 3600 <= s < 18 * 3600 assert forum_bot._weekly(Node(), day + s - 60, None, False, io.StringIO()) == [] assert len(forum_bot._weekly(Node(), day + s, None, False, io.StringIO())) == 1 # different keys land at different times slots = {forum_bot.slot(f"daily:2026-10-{d:02d}", *forum_bot.DAILY_WINDOW) for d in range(1, 15)} assert len(slots) > 10 and all(6 * 3600 <= x < 9 * 3600 for x in slots) def test_queue_publishes_founder_posts_on_time_and_holds_unresolved(tmp_path): import json q = tmp_path / "queue.json" q.write_text(json.dumps([ {"key": "ama", "publish_at": "2026-10-25T10:00:00Z", "category": "general", "title": "Ask us anything", "body": "Opening post", "pin": True}, {"key": "ama-2", "publish_at": "2026-10-26T15:00:00Z", "reply_to": "ama", "body": "Second batch"}, {"key": "draft", "publish_at": "2026-10-25T10:00:00Z", "title": "x", "body": "[NEEDS FOUNDER INPUT: something]"}, ]), encoding="utf-8") t0 = forum_bot._parse_time("2026-10-25T09:59:00Z") assert forum_bot.publish_queue(t0, str(q), out=io.StringIO()) == [] assert forum_bot.publish_queue(t0 + 120, str(q), out=io.StringIO()) == ["queue:ama"] thread = forum.pinned_threads()[0] assert thread["is_official"] and not thread["automated"] # written by a person assert forum_bot.publish_queue(t0 + 2 * 86400, str(q), out=io.StringIO()) == ["queue:ama-2"] assert forum_bot.publish_queue(t0 + 3 * 86400, str(q), out=io.StringIO()) == [] # once only assert forum.get_thread(thread["id"])["replies"][0]["body"] == "Second batch" assert forum.list_threads()["total"] == 0 # the held draft never posted