Files
vulncheck/tests/test_advisory_feed_limit.py
T
vulncheck 042e5aa6c7 feat(advisories): make the per-feed item cap configurable
30 items per feed was a constant. It is now the setting
`advisory_feeds_max_items`, editable in the feed configuration panel, applied
to every feed and taking effect on the next refresh.

The range is 1–500 rather than unbounded, and that ceiling is load-bearing:
every item lands in the cached JSON of ONE settings row that the page reads
in full, so "no limit" means a row that grows forever and a page that stops
rendering. 500 per feed is a few hundred KB per source — a quarter of the BSI
advisories in one sitting, and still a page that loads.

The value is written through the generic settings API, which stores whatever
a text field produced, so the read path treats it as untrusted: "", "abc",
"50 ", '"120"', 0, -5, 999999, "inf", true, None and an unreachable database
all resolve to something usable — junk falls back to 30, out-of-range is
clamped — and it never raises, because a feed run must not fail over its own
limit. The editor rejects the same values up front with the bounds named,
rather than saving something that quietly becomes a different number.

Also bounds the feed download at 16 MB. The item limit is the operator's to
raise; the size of a stranger's response is not.
2026-08-13 12:14:40 +02:00

99 lines
3.8 KiB
Python

"""Per-feed item limit: configurable, and unbreakable by what gets stored.
The limit used to be the constant 30. It is now a setting, written through the
generic settings API — which stores whatever a text field produced. So the read
path has to survive "", "abc", "50 ", 0, -5, 999999, true, None and a JSON
string, and still hand the feed run a usable number. It never raises: a bad
value falls back to the default, an out-of-range one is clamped.
Run: python tests/test_advisory_feed_limit.py
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services.advisory_feed_service import ( # noqa: E402
DEFAULT_MAX_ITEMS, MAX_MAX_ITEMS, MIN_MAX_ITEMS,
_parse_items, clamp_max_items, get_max_items)
class _Row:
def __init__(self, value):
self.value = value
class _Query:
def __init__(self, row): self._row = row
def filter(self, *a, **kw): return self
def first(self): return self._row
class _DB:
def __init__(self, row=None, boom=False):
self._row, self._boom = row, boom
def query(self, *a, **kw):
if self._boom:
raise RuntimeError("database is having a day")
return _Query(self._row)
def _feed(n_items: int) -> bytes:
items = "".join(
f"<item><title>Advisory {i}</title><link>https://example.test/{i}</link>"
f"<pubDate>Wed, 13 Aug 2026 09:00:00 GMT</pubDate>"
f"<description>&lt;p&gt;body {i}&lt;/p&gt;</description></item>"
for i in range(n_items))
return f"<rss><channel>{items}</channel></rss>".encode()
def demo():
# Sensible values pass through untouched.
for good in (1, 30, 50, 200, 500, "1", "30", "500", "50.0", " 75 ", '"120"'):
assert MIN_MAX_ITEMS <= clamp_max_items(good) <= MAX_MAX_ITEMS, good
assert clamp_max_items(50) == 50
assert clamp_max_items(" 75 ") == 75
assert clamp_max_items('"120"') == 120 # JSON-quoted by the settings API
# Junk falls back to the default rather than exploding.
for junk in ("", " ", "abc", "30 items", None, [], {}, "NaN", "inf",
True, False, "1,5"):
assert clamp_max_items(junk) == DEFAULT_MAX_ITEMS, junk
# Out of range is pulled into range, in both directions.
assert clamp_max_items(0) == MIN_MAX_ITEMS
assert clamp_max_items(-5) == MIN_MAX_ITEMS
assert clamp_max_items("-999") == MIN_MAX_ITEMS
assert clamp_max_items(999999) == MAX_MAX_ITEMS
assert clamp_max_items("1e9") == MAX_MAX_ITEMS
# Reading the setting: unset, empty, junk and an unreachable database all
# end at the default — a feed run must never fail over its own limit.
assert get_max_items(_DB(None)) == DEFAULT_MAX_ITEMS
assert get_max_items(_DB(_Row(None))) == DEFAULT_MAX_ITEMS
assert get_max_items(_DB(_Row(""))) == DEFAULT_MAX_ITEMS
assert get_max_items(_DB(_Row("garbage"))) == DEFAULT_MAX_ITEMS
assert get_max_items(_DB(boom=True)) == DEFAULT_MAX_ITEMS
assert get_max_items(_DB(_Row("120"))) == 120
assert get_max_items(_DB(_Row("99999"))) == MAX_MAX_ITEMS
# And the limit is what actually governs parsing.
raw = _feed(300)
assert len(_parse_items(raw)) == DEFAULT_MAX_ITEMS # unchanged default
assert len(_parse_items(raw, 5)) == 5
assert len(_parse_items(raw, 250)) == 250
# Clamped to 500, so a 300-item feed yields all 300 — not a crash, and not
# 99999 either.
assert len(_parse_items(raw, 99999)) == 300
assert len(_parse_items(raw, "abc")) == DEFAULT_MAX_ITEMS
# A short feed returns what it has — the limit is a ceiling, not a quota.
assert len(_parse_items(_feed(3), 100)) == 3
assert _parse_items(_feed(1), 10)[0]["title"] == "Advisory 0"
print("ok advisory feed item limit is configurable and junk-proof")
if __name__ == "__main__":
demo()