95 lines
3.1 KiB
Python
95 lines
3.1 KiB
Python
"""Antispam C1 decision logic (SPEC §C.4) — pure, no database, no engine."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from webmail_plugin_antispam.policy import Settings, decide, matches
|
||
|
|
|
||
|
|
SETTINGS = Settings(
|
||
|
|
enabled_accounts=[],
|
||
|
|
move_threshold=12.0,
|
||
|
|
banner_threshold=6.0,
|
||
|
|
network_checks=False,
|
||
|
|
learn_from_provider_junk=True,
|
||
|
|
)
|
||
|
|
NO_RULES: dict[str, set[str]] = {"allow": set(), "deny": set()}
|
||
|
|
|
||
|
|
|
||
|
|
def _decide(sender: str = "s@example.com", rules=None, score=None, settings=SETTINGS) -> str:
|
||
|
|
return decide(sender=sender, rules=rules or NO_RULES, score=score, settings=settings)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("score", "expected"),
|
||
|
|
[
|
||
|
|
(0.0, "none"),
|
||
|
|
(5.9, "none"),
|
||
|
|
(6.0, "banner"),
|
||
|
|
(11.9, "banner"),
|
||
|
|
(12.0, "moved"),
|
||
|
|
(99.0, "moved"),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_thresholds_are_inclusive_lower_bounds(score: float, expected: str) -> None:
|
||
|
|
assert _decide(score=score) == expected
|
||
|
|
|
||
|
|
|
||
|
|
def test_unreachable_engine_fails_open() -> None:
|
||
|
|
"""score=None is "could not classify" — mail must still be delivered plainly."""
|
||
|
|
assert _decide(score=None) == "none"
|
||
|
|
|
||
|
|
|
||
|
|
def test_deny_list_moves_without_the_engine() -> None:
|
||
|
|
"""Acceptance 5: a deny-listed sender is filed with no engine involvement."""
|
||
|
|
rules = {"allow": set(), "deny": {"spammer@bad.test"}}
|
||
|
|
assert _decide(sender="spammer@bad.test", rules=rules, score=None) == "deny_listed"
|
||
|
|
|
||
|
|
|
||
|
|
def test_allow_list_beats_a_high_score() -> None:
|
||
|
|
"""Acceptance 5: an allow-listed sender survives even an overwhelming score."""
|
||
|
|
rules = {"allow": {"boss@work.test"}, "deny": set()}
|
||
|
|
assert _decide(sender="boss@work.test", rules=rules, score=100.0) == "allow_listed"
|
||
|
|
|
||
|
|
|
||
|
|
def test_deny_beats_allow() -> None:
|
||
|
|
"""Both lists naming the same sender: the refusal wins."""
|
||
|
|
rules = {"allow": {"x@y.test"}, "deny": {"x@y.test"}}
|
||
|
|
assert _decide(sender="x@y.test", rules=rules, score=0.0) == "deny_listed"
|
||
|
|
|
||
|
|
|
||
|
|
def test_domain_patterns_cover_every_sender_there() -> None:
|
||
|
|
rules = {"allow": set(), "deny": {"@bad.test"}}
|
||
|
|
assert _decide(sender="anyone@bad.test", rules=rules) == "deny_listed"
|
||
|
|
assert _decide(sender="anyone@good.test", rules=rules) == "none"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("patterns", "email", "hit"),
|
||
|
|
[
|
||
|
|
({"a@b.test"}, "A@B.TEST", True), # case-insensitive
|
||
|
|
({"a@b.test"}, " a@b.test ", True), # tolerant of stray whitespace
|
||
|
|
({"@b.test"}, "anyone@b.test", True),
|
||
|
|
({"@b.test"}, "anyone@other.test", False),
|
||
|
|
({"a@b.test"}, "", False), # no sender at all
|
||
|
|
(set(), "a@b.test", False),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_pattern_matching(patterns: set[str], email: str, hit: bool) -> None:
|
||
|
|
assert matches(patterns, email) is hit
|
||
|
|
|
||
|
|
|
||
|
|
def test_per_account_enablement() -> None:
|
||
|
|
"""Empty list means every account; a populated one is an allow-list."""
|
||
|
|
everywhere = SETTINGS
|
||
|
|
assert everywhere.covers("any-account-id")
|
||
|
|
|
||
|
|
scoped = Settings(
|
||
|
|
enabled_accounts=["acc-1"],
|
||
|
|
move_threshold=12.0,
|
||
|
|
banner_threshold=6.0,
|
||
|
|
network_checks=False,
|
||
|
|
learn_from_provider_junk=True,
|
||
|
|
)
|
||
|
|
assert scoped.covers("acc-1")
|
||
|
|
assert not scoped.covers("acc-2")
|