Add web frontend with JWT auth, RBAC, SSE dashboard, and config editor

- FastAPI + htmx + Jinja2 web frontend, started with --web flag
- JWT HS256 auth (WEB_SECRET_KEY) with httpOnly cookies; access (15 min) +
  refresh (7 day) tokens; refresh rotation + JTI revocation in data/web.db
- RBAC: superadmin > admin > reader enforced per route
- Live SSE dashboard fed by tui/events broadcast queue
- Config editor: keyword groups and channel list saved to data/runtime_config.json
  and hot-reloaded in-process (scorer.reload_from_config, signal_channel_changed)
- config.py migrated to load groups/channels from runtime_config.json;
  falls back to hardcoded defaults when file absent
- tui/events.py: subscribe/unsubscribe broadcast, set_bot_context/signal_channel_changed
- utils/scorer.py: import config as _config (fixes local binding); reload_from_config()
- utils/database.py: count_by_severity, recent_for_domains, count_by_severity_for_domains
- 53 new tests (events bus, JWT lifecycle, web DB CRUD, RBAC enforcement,
  config round-trip); total 141 passing
This commit is contained in:
2026-04-02 11:41:46 -03:00
parent b28168c846
commit 4c104cddd2
32 changed files with 2093 additions and 47 deletions

View File

@@ -7,6 +7,11 @@ os.environ.setdefault("API_HASH", "dummy_hash_for_tests")
os.environ.setdefault("BOT_TOKEN", "0:dummy_bot_token")
os.environ.setdefault("NOTIFY_CHAT_ID", "99999")
# Web frontend test defaults — set once here so all web test files see the same values.
os.environ.setdefault("WEB_SECRET_KEY", "test-secret-key-for-pytest")
os.environ.setdefault("WEB_ADMIN_USER", "superadmin")
os.environ.setdefault("WEB_ADMIN_PASS", "superpass")
import pytest
import config
import utils.scorer as scorer
@@ -22,10 +27,10 @@ def patched_keywords(monkeypatch):
"""
Override TARGET_KEYWORDS for the duration of a test and rebuild the
scorer's module-level globals so scoring logic uses known test patterns.
scorer.py now reads _config.TARGET_KEYWORDS at call time via `import config as _config`,
so patching config.TARGET_KEYWORDS is sufficient — no direct scorer patch needed.
"""
monkeypatch.setattr(config, "TARGET_KEYWORDS", TEST_KEYWORDS)
# scorer.py uses `from config import TARGET_KEYWORDS` — a local binding that
# doesn't update when config.TARGET_KEYWORDS is patched. Patch it directly.
monkeypatch.setattr(scorer, "TARGET_KEYWORDS", TEST_KEYWORDS)
monkeypatch.setattr(scorer, "EMPLOYEE_DOMAINS", scorer._build_employee_domains())
monkeypatch.setattr(scorer, "ORG_DOMAINS", scorer._build_org_domains())

115
tests/test_events.py Normal file
View File

@@ -0,0 +1,115 @@
"""
Tests for tui/events.py — subscribe/unsubscribe broadcast, signal_channel_changed.
"""
import queue
import pytest
from tui import events as bus
@pytest.fixture(autouse=True)
def reset_bus():
"""Reset all bus state between tests."""
bus._queue = None
bus.tui_active = False
bus._subscribers.clear()
bus._bot_loop = None
bus._bot_ch_ev = None
yield
bus._queue = None
bus.tui_active = False
bus._subscribers.clear()
bus._bot_loop = None
bus._bot_ch_ev = None
class TestInitBus:
def test_init_creates_queue(self):
q = bus.init_bus()
assert q is not None
assert bus.tui_active is True
def test_get_bus_returns_same_queue(self):
q = bus.init_bus()
assert bus.get_bus() is q
class TestPost:
def test_post_before_init_is_silent(self):
bus.post("event") # should not raise
def test_post_reaches_tui_queue(self):
q = bus.init_bus()
bus.post("hello")
assert q.get_nowait() == "hello"
def test_post_reaches_subscriber(self):
bus.init_bus()
sub = bus.subscribe()
bus.post("world")
assert sub.get_nowait() == "world"
def test_post_reaches_multiple_subscribers(self):
bus.init_bus()
s1 = bus.subscribe()
s2 = bus.subscribe()
bus.post(42)
assert s1.get_nowait() == 42
assert s2.get_nowait() == 42
class TestSubscribeUnsubscribe:
def test_subscribe_returns_queue(self):
q = bus.subscribe()
assert isinstance(q, queue.Queue)
assert q in bus._subscribers
def test_unsubscribe_removes_queue(self):
q = bus.subscribe()
bus.unsubscribe(q)
assert q not in bus._subscribers
def test_unsubscribe_twice_is_safe(self):
q = bus.subscribe()
bus.unsubscribe(q)
bus.unsubscribe(q) # should not raise
def test_unsubscribed_does_not_receive(self):
bus.init_bus()
sub = bus.subscribe()
bus.unsubscribe(sub)
bus.post("gone")
with pytest.raises(queue.Empty):
sub.get_nowait()
class TestSignalChannelChanged:
def test_signal_without_context_is_safe(self):
bus.signal_channel_changed() # should not raise
def test_set_bot_context_stores_refs(self):
import asyncio
async def _inner():
loop = asyncio.get_event_loop()
ev = asyncio.Event()
bus.set_bot_context(loop, ev)
assert bus._bot_loop is loop
assert bus._bot_ch_ev is ev
asyncio.run(_inner())
def test_signal_sets_event(self):
import asyncio
async def _inner():
loop = asyncio.get_event_loop()
ev = asyncio.Event()
bus.set_bot_context(loop, ev)
assert not ev.is_set()
bus.signal_channel_changed()
# give the call_soon_threadsafe a chance to fire
await asyncio.sleep(0)
assert ev.is_set()
asyncio.run(_inner())

63
tests/test_web_auth.py Normal file
View File

@@ -0,0 +1,63 @@
"""
Tests for web/auth.py — JWT token lifecycle, bcrypt helpers.
"""
import pytest
from datetime import datetime, timedelta, timezone
from web import auth
class TestPasswordHashing:
def test_hash_and_verify(self):
h = auth.hash_password("hunter2")
assert auth.verify_password("hunter2", h)
def test_wrong_password_fails(self):
h = auth.hash_password("hunter2")
assert not auth.verify_password("wrong", h)
class TestAccessToken:
def test_encode_decode_roundtrip(self):
token = auth.create_access_token("user-1", "admin")
payload = auth.decode_access_token(token)
assert payload is not None
assert payload["sub"] == "user-1"
assert payload["role"] == "admin"
assert payload["type"] == "access"
def test_invalid_token_returns_none(self):
assert auth.decode_access_token("not.a.token") is None
def test_tampered_token_returns_none(self):
token = auth.create_access_token("user-1", "admin")
# Flip last character
tampered = token[:-1] + ("A" if token[-1] != "A" else "B")
assert auth.decode_access_token(tampered) is None
def test_refresh_token_rejected_as_access(self):
token, _jti, _exp = auth.create_refresh_token("user-1")
# A refresh token must NOT be accepted as an access token
assert auth.decode_access_token(token) is None
class TestRefreshToken:
def test_encode_decode_roundtrip(self):
token, jti, expires_at = auth.create_refresh_token("user-2")
payload = auth.decode_refresh_token(token)
assert payload is not None
assert payload["sub"] == "user-2"
assert payload["jti"] == jti
assert payload["type"] == "refresh"
def test_expires_at_in_future(self):
_token, _jti, expires_at = auth.create_refresh_token("user-2")
assert expires_at > datetime.now(timezone.utc)
def test_access_token_rejected_as_refresh(self):
token = auth.create_access_token("user-1", "reader")
assert auth.decode_refresh_token(token) is None
def test_invalid_token_returns_none(self):
assert auth.decode_refresh_token("garbage") is None

108
tests/test_web_db.py Normal file
View File

@@ -0,0 +1,108 @@
"""
Tests for web/db.py — user store and refresh token management.
"""
import pytest
from pathlib import Path
@pytest.fixture
def tmp_db(tmp_path, monkeypatch):
"""Point web.db at a temp file for each test."""
import web.db as db_mod
db_path = tmp_path / "test_web.db"
monkeypatch.setattr(db_mod, "DB_FILE", db_path)
db_mod.init_db()
return db_mod
class TestInitDb:
def test_creates_superadmin_on_first_run(self, tmp_db):
import os
admin_user = os.environ["WEB_ADMIN_USER"]
user = tmp_db.get_user_by_username(admin_user)
assert user is not None
assert user["role"] == "superadmin"
assert user["is_active"] == 1
def test_second_init_does_not_duplicate(self, tmp_db):
import os
admin_user = os.environ["WEB_ADMIN_USER"]
tmp_db.init_db() # second call
users = tmp_db.list_users()
assert len([u for u in users if u["username"] == admin_user]) == 1
class TestUserCRUD:
def test_create_and_get_user(self, tmp_db):
uid = tmp_db.create_user("alice", "pass1", "reader")
user = tmp_db.get_user_by_id(uid)
assert user["username"] == "alice"
assert user["role"] == "reader"
def test_get_by_username(self, tmp_db):
tmp_db.create_user("bob", "pass2", "admin")
user = tmp_db.get_user_by_username("bob")
assert user is not None
assert user["role"] == "admin"
def test_update_role(self, tmp_db):
uid = tmp_db.create_user("carol", "pass3", "reader")
tmp_db.update_user(uid, role="admin")
user = tmp_db.get_user_by_id(uid)
assert user["role"] == "admin"
def test_update_password_is_hashed(self, tmp_db):
from web.auth import verify_password
uid = tmp_db.create_user("dave", "oldpass", "reader")
tmp_db.update_user(uid, password="newpass")
user = tmp_db.get_user_by_id(uid)
assert verify_password("newpass", user["password_hash"])
assert not verify_password("oldpass", user["password_hash"])
def test_deactivate_user(self, tmp_db):
uid = tmp_db.create_user("eve", "pass4", "reader")
tmp_db.deactivate_user(uid)
# get_user_by_username filters is_active=1
assert tmp_db.get_user_by_username("eve") is None
# get_user_by_id still returns the row
user = tmp_db.get_user_by_id(uid)
assert user["is_active"] == 0
def test_list_users(self, tmp_db):
tmp_db.create_user("u1", "p", "reader")
tmp_db.create_user("u2", "p", "admin")
users = tmp_db.list_users()
usernames = [u["username"] for u in users]
assert "u1" in usernames
assert "u2" in usernames
class TestRefreshTokens:
def test_store_and_validate(self, tmp_db):
from datetime import datetime, timedelta, timezone
import uuid
jti = str(uuid.uuid4())
expires_at = datetime.now(timezone.utc) + timedelta(days=7)
tmp_db.store_refresh_token(jti, "user-x", expires_at)
assert tmp_db.is_refresh_token_valid(jti)
def test_revoked_token_invalid(self, tmp_db):
from datetime import datetime, timedelta, timezone
import uuid
jti = str(uuid.uuid4())
expires_at = datetime.now(timezone.utc) + timedelta(days=7)
tmp_db.store_refresh_token(jti, "user-x", expires_at)
tmp_db.revoke_refresh_token(jti)
assert not tmp_db.is_refresh_token_valid(jti)
def test_expired_token_invalid(self, tmp_db):
from datetime import datetime, timedelta, timezone
import uuid
jti = str(uuid.uuid4())
expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
tmp_db.store_refresh_token(jti, "user-x", expires_at)
assert not tmp_db.is_refresh_token_valid(jti)
def test_unknown_jti_invalid(self, tmp_db):
assert not tmp_db.is_refresh_token_valid("nonexistent-jti")

191
tests/test_web_rbac.py Normal file
View File

@@ -0,0 +1,191 @@
"""
Tests for web RBAC enforcement and config JSON round-trip.
Uses FastAPI TestClient with a temporary web.db.
"""
import json
import pytest
@pytest.fixture(autouse=True)
def isolated_web(tmp_path, monkeypatch):
"""Redirect web.db and runtime_config.json to tmp dirs for each test."""
from pathlib import Path
import web.db as db_mod
import config as cfg_mod
db_path = tmp_path / "web.db"
cfg_path = tmp_path / "runtime_config.json"
monkeypatch.setattr(db_mod, "DB_FILE", db_path)
monkeypatch.setattr(cfg_mod, "RUNTIME_CONFIG_PATH", cfg_path)
db_mod.init_db()
@pytest.fixture
def client():
from fastapi.testclient import TestClient
from web.app import create_app
app = create_app()
return TestClient(app, raise_server_exceptions=True)
def _login(client, username="superadmin", password="superpass") -> dict:
"""Log in and return the cookies dict."""
r = client.post("/login", data={"username": username, "password": password},
follow_redirects=False)
assert r.status_code == 303, f"Login failed: {r.text}"
return client.cookies
# ─── Auth flow ────────────────────────────────────────────────────────────────
class TestAuthFlow:
def test_login_sets_cookies(self, client):
r = client.post("/login", data={"username": "superadmin", "password": "superpass"},
follow_redirects=False)
assert r.status_code == 303
assert "access_token" in r.cookies
assert "refresh_token" in r.cookies
def test_invalid_password_returns_401(self, client):
r = client.post("/login", data={"username": "superadmin", "password": "wrong"},
follow_redirects=False)
assert r.status_code == 401
def test_unauthenticated_dashboard_redirected_or_401(self, client):
r = client.get("/dashboard", follow_redirects=False)
assert r.status_code in (401, 302, 303)
def test_logout_clears_cookies(self, client):
_login(client)
r = client.post("/logout", follow_redirects=False)
assert r.status_code == 303
def test_authenticated_dashboard_ok(self, client):
_login(client)
r = client.get("/dashboard")
assert r.status_code == 200
# ─── RBAC ─────────────────────────────────────────────────────────────────────
class TestRBAC:
def test_reader_cannot_access_config(self, client):
import web.db as db_mod
db_mod.create_user("reader1", "pass", "reader")
_login(client, "reader1", "pass")
r = client.get("/config/keywords")
assert r.status_code == 403
def test_admin_can_access_config(self, client):
import web.db as db_mod
db_mod.create_user("admin1", "pass", "admin")
_login(client, "admin1", "pass")
r = client.get("/config/keywords")
assert r.status_code == 200
def test_reader_cannot_access_users(self, client):
import web.db as db_mod
db_mod.create_user("reader2", "pass", "reader")
_login(client, "reader2", "pass")
r = client.get("/users")
assert r.status_code == 403
def test_admin_cannot_access_users(self, client):
import web.db as db_mod
db_mod.create_user("admin2", "pass", "admin")
_login(client, "admin2", "pass")
r = client.get("/users")
assert r.status_code == 403
def test_superadmin_can_access_users(self, client):
_login(client)
r = client.get("/users")
assert r.status_code == 200
# ─── Config round-trip ────────────────────────────────────────────────────────
class TestConfigRoundTrip:
def test_put_keywords_saves_and_reloads(self, client):
import config
_login(client)
groups = [
{
"id": "testorg",
"name": "Test Org",
"patterns": [
{"regex": r"testorg\.com", "label": "Domain"},
{"regex": r"@testorg\.com", "label": "Employees"},
],
}
]
r = client.put("/config/keywords", json={"groups": groups})
assert r.status_code == 200
assert r.json()["groups"] == 1
# Config module globals updated in-process
assert any("testorg" in kw for kw in config.TARGET_KEYWORDS)
def test_put_keywords_invalid_regex_rejected(self, client):
_login(client)
groups = [
{
"id": "bad",
"name": "Bad",
"patterns": [{"regex": "[invalid(regex", "label": "oops"}],
}
]
r = client.put("/config/keywords", json={"groups": groups})
assert r.status_code == 422
def test_put_channels_saves(self, client):
import config
_login(client)
channels = ["testchannel", -1002748707556]
r = client.put("/config/channels", json={"channels": channels})
assert r.status_code == 200
assert config.WATCHED_CHANNELS == channels
# ─── User management ─────────────────────────────────────────────────────────
class TestUserManagement:
def test_create_user(self, client):
_login(client)
r = client.post("/users", json={"username": "newuser", "password": "pass", "role": "reader"})
assert r.status_code == 200
assert "id" in r.json()
def test_cannot_create_duplicate_username(self, client):
_login(client)
client.post("/users", json={"username": "dup", "password": "p", "role": "reader"})
r = client.post("/users", json={"username": "dup", "password": "p", "role": "reader"})
assert r.status_code == 409
def test_update_user_role(self, client):
import web.db as db_mod
_login(client)
uid = db_mod.create_user("patchme", "p", "reader")
r = client.patch(f"/users/{uid}", json={"role": "admin"})
assert r.status_code == 200
assert db_mod.get_user_by_id(uid)["role"] == "admin"
def test_deactivate_user(self, client):
import web.db as db_mod
_login(client)
uid = db_mod.create_user("byebye", "p", "reader")
r = client.delete(f"/users/{uid}")
assert r.status_code == 200
assert db_mod.get_user_by_id(uid)["is_active"] == 0
def test_cannot_deactivate_self(self, client):
import web.db as db_mod
_login(client)
me = db_mod.get_user_by_username("superadmin")
r = client.delete(f"/users/{me['id']}")
assert r.status_code == 403