Files
webmail-client/backend/tests/plugins/test_antispam_api.py
T
lukasandClaude Opus 5 fe471dbf01 Antispam: mirror the engine in dev, and never file a contact as spam
Dev compose gains rspamd + redis-rspamd, matching production. The dev API and
worker run on the host rather than in the compose network, so the ports are
published on loopback and RSPAMD_URL points at 127.0.0.1 — documented in
.env.example.

Contacts now cap a verdict at "banner": mail from someone in the user's address
book is never moved to Junk. Capped rather than exempted, because the way this
fails in practice is a contact's account being compromised and spamming its own
address book — the warning still has value even when the move would be wrong.
An explicit deny rule still wins; that is the user overruling us deliberately.

The lookup reads the contacts plugin's table directly and forgives its absence:
no contacts plugin means no contacts to check, not an error. Addresses are
stored as the user typed them, so both sides are lower-cased rather than relying
on the jsonb containment index, and secondary addresses in the emails array
count as well as the primary. It runs only when the verdict would otherwise be a
move, so the common path pays nothing.

Also fixes a test that asserted stats degrade "without an engine" by relying on
no engine being present — true until this commit. It now simulates the absence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-17 08:09:24 +02:00

185 lines
6.9 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_verdict_absent_for_unclassified_message(ctx: _Ctx) -> None:
assert (await ctx.client_a.get(f"{BASE}/verdict/{uuid.uuid4()}")).json() is None
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", "actions.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