A button pinned to every message header is a persistent control; marking spam is a verb you reach for occasionally, like Reply or Delete. It now lives in the Actions menu, between Labels and Delete. That needed a slot inside the dropdown, which the catalog had no name for: `message.actions.menu`. The host wraps it so any click closes the menu, leaving a bundle to do nothing but act. The UI kit gains `.wm-menu-item`, @applying the exact utilities ThreadView's own menu items use — the same trick the sidebar shell already relies on, so a plugin verb is indistinguishable from a core one rather than approximately similar. Labels follow the folder as before: "Mark as Spam" in the inbox or archive, "Mark as not spam" in the spam folder, nothing in Sent/Drafts/Trash. A message already taught shows that, disabled, instead of offering the lesson again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
189 lines
7.1 KiB
Python
189 lines
7.1 KiB
Python
"""Antispam HTTP surface + RLS isolation (SPEC §C.6, acceptance 1 and 4).
|
|
|
|
Exercises the real vertical: entry-point discovery -> router at
|
|
/api/plugins/antispam -> SDK auth -> RLS-scoped session -> the plg_antispam_*
|
|
tables. Requires the plugin installed and `install antispam` run.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from collections.abc import AsyncIterator
|
|
|
|
import httpx
|
|
import pytest
|
|
import pytest_asyncio
|
|
from httpx import ASGITransport
|
|
from sqlalchemy import delete, text
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
# NB: `from webmail_plugin_antispam import router` would bind the APIRouter
|
|
# object, not the module — __init__ re-exports it. Patch the engine module,
|
|
# which router.py resolves by attribute at call time.
|
|
from webmail_plugin_antispam import rspamd as rspamd_mod
|
|
|
|
from app.config import get_settings
|
|
from app.identity.passwords import hash_password
|
|
from app.identity.sessions import create_session
|
|
from app.main import create_app
|
|
from app.models import User
|
|
|
|
BASE = "/api/plugins/antispam"
|
|
|
|
|
|
async def _mk_user(sm: async_sessionmaker) -> uuid.UUID: # type: ignore[type-arg]
|
|
async with sm() as s:
|
|
user = User(
|
|
handle=f"as{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")
|
|
)
|
|
s.add(user)
|
|
await s.commit()
|
|
return user.id
|
|
|
|
|
|
class _Ctx:
|
|
user_a: uuid.UUID
|
|
user_b: uuid.UUID
|
|
client_a: httpx.AsyncClient
|
|
client_b: httpx.AsyncClient
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def ctx() -> AsyncIterator[_Ctx]:
|
|
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
|
sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
c = _Ctx()
|
|
c.user_a = await _mk_user(sm)
|
|
c.user_b = await _mk_user(sm)
|
|
transport = ASGITransport(app=create_app())
|
|
|
|
async def client(uid: uuid.UUID) -> httpx.AsyncClient:
|
|
token = await create_session(uid, "pytest")
|
|
return httpx.AsyncClient(
|
|
transport=transport,
|
|
base_url="http://testserver",
|
|
cookies={"session": token, "csrf": "csrf"},
|
|
headers={"X-CSRF": "csrf"},
|
|
)
|
|
|
|
c.client_a = await client(c.user_a)
|
|
c.client_b = await client(c.user_b)
|
|
try:
|
|
yield c
|
|
finally:
|
|
await c.client_a.aclose()
|
|
await c.client_b.aclose()
|
|
async with sm() as s:
|
|
for table in ("plg_antispam_rules", "plg_antispam_settings", "plg_antispam_verdicts"):
|
|
await s.execute(
|
|
text(f"DELETE FROM {table} WHERE user_id IN (:a, :b)"),
|
|
{"a": str(c.user_a), "b": str(c.user_b)},
|
|
)
|
|
await s.execute(delete(User).where(User.id.in_([c.user_a, c.user_b])))
|
|
await s.commit()
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_settings_defaults_then_merge(ctx: _Ctx) -> None:
|
|
"""A user who has never opened the page still gets the §C.3 defaults."""
|
|
s = (await ctx.client_a.get(f"{BASE}/settings")).json()
|
|
assert (s["move_threshold"], s["banner_threshold"]) == (12.0, 6.0)
|
|
assert s["network_checks"] is False # privacy default (§C.2)
|
|
assert s["enabled_accounts"] == [] # empty = every account
|
|
|
|
# PATCH merges: the field we leave out keeps its value.
|
|
s = (await ctx.client_a.patch(f"{BASE}/settings", json={"move_threshold": 15.0})).json()
|
|
assert s["move_threshold"] == 15.0
|
|
assert s["banner_threshold"] == 6.0
|
|
|
|
|
|
async def test_banner_threshold_cannot_exceed_move(ctx: _Ctx) -> None:
|
|
"""A banner above the move score would never be seen — it is capped, not refused."""
|
|
s = (
|
|
await ctx.client_a.patch(
|
|
f"{BASE}/settings", json={"move_threshold": 10.0, "banner_threshold": 20.0}
|
|
)
|
|
).json()
|
|
assert s["banner_threshold"] == 10.0
|
|
|
|
|
|
async def test_rules_crud_and_validation(ctx: _Ctx) -> None:
|
|
created = await ctx.client_a.post(
|
|
f"{BASE}/rules", json={"kind": "deny", "pattern": "@Bad.TEST"}
|
|
)
|
|
assert created.status_code == 200
|
|
assert created.json()["pattern"] == "@bad.test" # normalised
|
|
|
|
listed = (await ctx.client_a.get(f"{BASE}/rules")).json()
|
|
assert [r["pattern"] for r in listed] == ["@bad.test"]
|
|
|
|
# Re-adding the same pattern is idempotent, not a duplicate.
|
|
await ctx.client_a.post(f"{BASE}/rules", json={"kind": "deny", "pattern": "@bad.test"})
|
|
assert len((await ctx.client_a.get(f"{BASE}/rules")).json()) == 1
|
|
|
|
assert (
|
|
await ctx.client_a.post(f"{BASE}/rules", json={"kind": "deny", "pattern": "nonsense"})
|
|
).status_code == 400
|
|
assert (
|
|
await ctx.client_a.post(f"{BASE}/rules", json={"kind": "maybe", "pattern": "a@b.test"})
|
|
).status_code == 422
|
|
|
|
await ctx.client_a.delete(f"{BASE}/rules/{listed[0]['id']}")
|
|
assert (await ctx.client_a.get(f"{BASE}/rules")).json() == []
|
|
|
|
|
|
async def test_rls_isolation_between_users(ctx: _Ctx) -> None:
|
|
"""Acceptance 4's per-user isolation, at the storage layer."""
|
|
await ctx.client_a.post(f"{BASE}/rules", json={"kind": "allow", "pattern": "a@only.test"})
|
|
await ctx.client_a.patch(f"{BASE}/settings", json={"move_threshold": 9.0})
|
|
|
|
assert (await ctx.client_b.get(f"{BASE}/rules")).json() == []
|
|
assert (await ctx.client_b.get(f"{BASE}/settings")).json()["move_threshold"] == 12.0
|
|
|
|
# B cannot delete A's rule even knowing its id.
|
|
a_rule = (await ctx.client_a.get(f"{BASE}/rules")).json()[0]
|
|
await ctx.client_b.delete(f"{BASE}/rules/{a_rule['id']}")
|
|
assert len((await ctx.client_a.get(f"{BASE}/rules")).json()) == 1
|
|
|
|
|
|
async def test_context_for_an_unknown_message(ctx: _Ctx) -> None:
|
|
"""The slots ask about every message they mount on, including ones we have
|
|
never classified — that must answer, not 404."""
|
|
body = (await ctx.client_a.get(f"{BASE}/context/{uuid.uuid4()}")).json()
|
|
assert body["verdict"] is None
|
|
assert body["special_use"] is None # not this user's message: no folder to report
|
|
|
|
|
|
async def test_stats_work_without_an_engine(ctx: _Ctx, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The stats page must render on a box where Rspamd is not running.
|
|
|
|
Simulated rather than inferred from the environment: the dev stack now runs
|
|
an engine, so "no engine" has to be arranged deliberately or this test only
|
|
passes by accident.
|
|
"""
|
|
|
|
async def _no_engine(**_kw: object) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(rspamd_mod, "stats", _no_engine)
|
|
body = (await ctx.client_a.get(f"{BASE}/stats")).json()
|
|
assert body["counts"] == {}
|
|
assert body["top_symbols"] == []
|
|
assert body["bayes"] is None # unreachable engine, not an error
|
|
|
|
|
|
async def test_stats_include_engine_state_when_it_is_up(ctx: _Ctx) -> None:
|
|
"""And when an engine IS present, its numbers come through."""
|
|
|
|
body = (await ctx.client_a.get(f"{BASE}/stats")).json()
|
|
assert body["bayes"] is None or isinstance(body["bayes"], dict)
|
|
|
|
|
|
async def test_ui_bundles_are_served_and_gated(ctx: _Ctx) -> None:
|
|
for name in ("settings.js", "banner.js", "menu.js"):
|
|
r = await ctx.client_a.get(f"{BASE}/ui/{name}")
|
|
assert r.status_code == 200
|
|
assert "javascript" in r.headers["content-type"]
|
|
assert (await ctx.client_a.get(f"{BASE}/ui/../hook.py")).status_code == 404
|