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:
@@ -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
115
tests/test_events.py
Normal 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
63
tests/test_web_auth.py
Normal 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
108
tests/test_web_db.py
Normal 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
191
tests/test_web_rbac.py
Normal 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
|
||||
Reference in New Issue
Block a user