Files
DocsGPT/tests/test_version_check.py
2026-04-21 14:22:32 +01:00

403 lines
14 KiB
Python

"""Unit tests for the anonymous startup version-check client.
All external dependencies (Postgres, Redis, HTTP) are mocked so the
suite runs in pure-Python isolation. The focus is on the branching
behavior described in the spec: opt-out, cache-hit, cache-miss,
lock-denied, and the various failure paths that must never propagate.
"""
from __future__ import annotations
import json
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
import requests
from application.updates import version_check as vc_module
class _FakeRepo:
"""Stand-in for AppMetadataRepository backed by a plain dict."""
def __init__(self, store: dict | None = None, *, raise_on_get_instance: bool = False):
self._store: dict[str, str] = dict(store) if store else {}
self._raise = raise_on_get_instance
def get(self, key: str):
return self._store.get(key)
def set(self, key: str, value: str) -> None:
self._store[key] = value
def get_or_create_instance_id(self) -> str:
if self._raise:
raise RuntimeError("simulated Postgres outage")
existing = self._store.get("instance_id")
if existing:
return existing
self._store["instance_id"] = "11111111-2222-3333-4444-555555555555"
return self._store["instance_id"]
@contextmanager
def _fake_db_session():
"""Stand-in for ``db_session()`` — yields ``None`` because the fake
repository ignores its connection argument."""
yield None
def _install_repo(monkeypatch, repo: _FakeRepo):
"""Patch the repo constructor so ``AppMetadataRepository(conn)`` → ``repo``."""
monkeypatch.setattr(
vc_module, "AppMetadataRepository", lambda conn: repo
)
def _install_db_session(monkeypatch, *, raise_exc: Exception | None = None):
if raise_exc is not None:
@contextmanager
def boom():
raise raise_exc
yield # pragma: no cover - unreachable
monkeypatch.setattr(vc_module, "db_session", boom)
else:
monkeypatch.setattr(vc_module, "db_session", _fake_db_session)
def _make_redis_mock(*, get_return=None, set_return=True):
client = MagicMock()
client.get.return_value = get_return
client.set.return_value = set_return
client.setex.return_value = True
client.delete.return_value = 1
return client
@pytest.fixture
def enable_check(monkeypatch):
monkeypatch.setattr(vc_module.settings, "VERSION_CHECK", True)
@pytest.mark.unit
def test_opt_out_short_circuits(monkeypatch):
"""VERSION_CHECK=0 → no Postgres, no Redis, no network."""
monkeypatch.setattr(vc_module.settings, "VERSION_CHECK", False)
db_spy = MagicMock()
redis_spy = MagicMock()
post_spy = MagicMock()
monkeypatch.setattr(vc_module, "db_session", db_spy)
monkeypatch.setattr(vc_module, "get_redis_instance", redis_spy)
monkeypatch.setattr(vc_module.requests, "post", post_spy)
vc_module.run_check()
db_spy.assert_not_called()
redis_spy.assert_not_called()
post_spy.assert_not_called()
@pytest.mark.unit
def test_cache_hit_renders_without_lock_or_network(monkeypatch, enable_check, capsys):
repo = _FakeRepo({"version_check_notice_shown": "1"})
_install_repo(monkeypatch, repo)
_install_db_session(monkeypatch)
cached = {
"advisories": [
{
"id": "DOCSGPT-TEST-1",
"title": "Example",
"severity": "high",
"fixed_in": "0.17.0",
"url": "https://example.test/a",
"summary": "Upgrade required.",
}
]
}
redis_client = _make_redis_mock(get_return=json.dumps(cached).encode("utf-8"))
monkeypatch.setattr(vc_module, "get_redis_instance", lambda: redis_client)
post_spy = MagicMock()
monkeypatch.setattr(vc_module.requests, "post", post_spy)
vc_module.run_check()
redis_client.get.assert_called_once_with(vc_module.CACHE_KEY)
redis_client.set.assert_not_called()
redis_client.setex.assert_not_called()
post_spy.assert_not_called()
assert "SECURITY ADVISORY: DOCSGPT-TEST-1" in capsys.readouterr().out
@pytest.mark.unit
def test_cache_miss_lock_acquired_fetches_and_caches(monkeypatch, enable_check):
repo = _FakeRepo({"version_check_notice_shown": "1"})
_install_repo(monkeypatch, repo)
_install_db_session(monkeypatch)
redis_client = _make_redis_mock(get_return=None, set_return=True)
monkeypatch.setattr(vc_module, "get_redis_instance", lambda: redis_client)
response_body = {
"advisories": [
{
"id": "DOCSGPT-LOW-1",
"title": "Minor",
"severity": "low",
"fixed_in": "0.17.0",
"url": "https://example.test/low",
}
],
"next_check_after": 1800,
}
post_response = MagicMock()
post_response.status_code = 200
post_response.json.return_value = response_body
post_spy = MagicMock(return_value=post_response)
monkeypatch.setattr(vc_module.requests, "post", post_spy)
vc_module.run_check()
post_spy.assert_called_once()
call_kwargs = post_spy.call_args
assert call_kwargs.args[0] == vc_module.ENDPOINT_URL
payload = call_kwargs.kwargs["json"]
assert payload["client"] == "docsgpt-backend"
assert payload["instance_id"] == "11111111-2222-3333-4444-555555555555"
assert "version" in payload and "python_version" in payload
# Lock acquired with NX EX, cache written with server-specified TTL,
# lock released.
redis_client.set.assert_called_once()
set_kwargs = redis_client.set.call_args.kwargs
assert set_kwargs == {"nx": True, "ex": vc_module.LOCK_TTL_SECONDS}
redis_client.setex.assert_called_once()
setex_args = redis_client.setex.call_args.args
assert setex_args[0] == vc_module.CACHE_KEY
assert setex_args[1] == 1800 # server override under 6h
redis_client.delete.assert_called_once_with(vc_module.LOCK_KEY)
@pytest.mark.unit
def test_cache_miss_lock_denied_skips_silently(monkeypatch, enable_check):
repo = _FakeRepo({"version_check_notice_shown": "1"})
_install_repo(monkeypatch, repo)
_install_db_session(monkeypatch)
redis_client = _make_redis_mock(get_return=None, set_return=False) # lock not acquired
monkeypatch.setattr(vc_module, "get_redis_instance", lambda: redis_client)
post_spy = MagicMock()
monkeypatch.setattr(vc_module.requests, "post", post_spy)
vc_module.run_check()
post_spy.assert_not_called()
redis_client.setex.assert_not_called()
redis_client.delete.assert_not_called()
@pytest.mark.unit
def test_instance_id_persisted_across_runs(monkeypatch, enable_check):
repo = _FakeRepo({"version_check_notice_shown": "1"})
_install_repo(monkeypatch, repo)
_install_db_session(monkeypatch)
redis_client = _make_redis_mock(get_return=None, set_return=True)
monkeypatch.setattr(vc_module, "get_redis_instance", lambda: redis_client)
post_response = MagicMock()
post_response.status_code = 200
post_response.json.return_value = {}
monkeypatch.setattr(
vc_module.requests, "post", MagicMock(return_value=post_response)
)
vc_module.run_check()
first_id = repo.get("instance_id")
vc_module.run_check()
second_id = repo.get("instance_id")
assert first_id is not None
assert first_id == second_id
@pytest.mark.unit
def test_first_run_notice_emitted_once(monkeypatch, enable_check, capsys):
repo = _FakeRepo() # empty — notice not shown yet
_install_repo(monkeypatch, repo)
_install_db_session(monkeypatch)
# Cache hit so we don't need to mock HTTP. Notice logic runs before cache.
redis_client = _make_redis_mock(get_return=json.dumps({}).encode("utf-8"))
monkeypatch.setattr(vc_module, "get_redis_instance", lambda: redis_client)
vc_module.run_check()
first_out = capsys.readouterr().out
assert "Anonymous version check enabled" in first_out
assert repo.get("version_check_notice_shown") == "1"
vc_module.run_check()
second_out = capsys.readouterr().out
assert "Anonymous version check enabled" not in second_out
@pytest.mark.unit
def test_postgres_unavailable_skips_silently(monkeypatch, enable_check):
_install_db_session(monkeypatch, raise_exc=RuntimeError("db down"))
redis_spy = MagicMock()
post_spy = MagicMock()
monkeypatch.setattr(vc_module, "get_redis_instance", redis_spy)
monkeypatch.setattr(vc_module.requests, "post", post_spy)
vc_module.run_check()
redis_spy.assert_not_called()
post_spy.assert_not_called()
@pytest.mark.unit
def test_postgres_repo_raises_skips_silently(monkeypatch, enable_check):
repo = _FakeRepo(raise_on_get_instance=True)
_install_repo(monkeypatch, repo)
_install_db_session(monkeypatch)
redis_spy = MagicMock()
post_spy = MagicMock()
monkeypatch.setattr(vc_module, "get_redis_instance", redis_spy)
monkeypatch.setattr(vc_module.requests, "post", post_spy)
vc_module.run_check()
redis_spy.assert_not_called()
post_spy.assert_not_called()
@pytest.mark.unit
def test_redis_unavailable_proceeds_uncached(monkeypatch, enable_check):
"""``get_redis_instance()`` → None should not abort the check."""
repo = _FakeRepo({"version_check_notice_shown": "1"})
_install_repo(monkeypatch, repo)
_install_db_session(monkeypatch)
monkeypatch.setattr(vc_module, "get_redis_instance", lambda: None)
post_response = MagicMock()
post_response.status_code = 200
post_response.json.return_value = {"advisories": []}
post_spy = MagicMock(return_value=post_response)
monkeypatch.setattr(vc_module.requests, "post", post_spy)
vc_module.run_check()
post_spy.assert_called_once()
@pytest.mark.unit
def test_unknown_version_warns_and_skips(monkeypatch, enable_check):
"""get_version() → "unknown" must not hit the endpoint silently."""
repo = _FakeRepo({"version_check_notice_shown": "1"})
_install_repo(monkeypatch, repo)
_install_db_session(monkeypatch)
redis_client = _make_redis_mock(get_return=None, set_return=True)
monkeypatch.setattr(vc_module, "get_redis_instance", lambda: redis_client)
monkeypatch.setattr(vc_module, "get_version", lambda: "unknown")
post_spy = MagicMock()
monkeypatch.setattr(vc_module.requests, "post", post_spy)
with patch.object(vc_module, "logger") as mock_logger:
vc_module.run_check()
post_spy.assert_not_called()
redis_client.setex.assert_not_called()
# Lock released so the next cycle can retry.
redis_client.delete.assert_called_once_with(vc_module.LOCK_KEY)
assert mock_logger.warning.called
assert "unknown" in mock_logger.warning.call_args.args[0].lower() \
or mock_logger.warning.call_args.args[1:] == ("unknown",)
@pytest.mark.unit
def test_http_5xx_swallowed(monkeypatch, enable_check):
repo = _FakeRepo({"version_check_notice_shown": "1"})
_install_repo(monkeypatch, repo)
_install_db_session(monkeypatch)
redis_client = _make_redis_mock(get_return=None, set_return=True)
monkeypatch.setattr(vc_module, "get_redis_instance", lambda: redis_client)
post_response = MagicMock()
post_response.status_code = 503
post_response.json.return_value = {}
monkeypatch.setattr(
vc_module.requests, "post", MagicMock(return_value=post_response)
)
vc_module.run_check()
redis_client.setex.assert_not_called()
# Lock still released so the next cycle can retry.
redis_client.delete.assert_called_once_with(vc_module.LOCK_KEY)
@pytest.mark.unit
def test_http_timeout_swallowed(monkeypatch, enable_check):
repo = _FakeRepo({"version_check_notice_shown": "1"})
_install_repo(monkeypatch, repo)
_install_db_session(monkeypatch)
redis_client = _make_redis_mock(get_return=None, set_return=True)
monkeypatch.setattr(vc_module, "get_redis_instance", lambda: redis_client)
monkeypatch.setattr(
vc_module.requests,
"post",
MagicMock(side_effect=requests.Timeout("boom")),
)
# Must not raise.
vc_module.run_check()
redis_client.setex.assert_not_called()
redis_client.delete.assert_called_once_with(vc_module.LOCK_KEY)
@pytest.mark.unit
def test_compute_ttl_honors_server_override():
assert vc_module._compute_ttl({"next_check_after": 300}) == 300
assert vc_module._compute_ttl({"next_check_after": 60000}) == vc_module.CACHE_TTL_SECONDS
assert vc_module._compute_ttl({}) == vc_module.CACHE_TTL_SECONDS
assert vc_module._compute_ttl({"next_check_after": "bad"}) == vc_module.CACHE_TTL_SECONDS
# Zero/negative overrides fall back to the 6h default.
assert vc_module._compute_ttl({"next_check_after": 0}) == vc_module.CACHE_TTL_SECONDS
@pytest.mark.unit
def test_render_advisories_logs_warning_and_prints_banner(monkeypatch, capsys):
with patch.object(vc_module, "logger") as mock_logger:
vc_module._render_advisories(
{
"advisories": [
{
"id": "DOCSGPT-2025-001",
"title": "SSRF",
"severity": "critical",
"fixed_in": "0.17.0",
"url": "https://example.test/a",
"summary": "Your DocsGPT is vulnerable.",
},
{
"id": "DOCSGPT-2025-002",
"title": "Low-sev",
"severity": "low",
},
]
}
)
# Both advisories logged as warnings.
assert mock_logger.warning.call_count == 2
out = capsys.readouterr().out
# Only the high/critical one gets the console banner.
assert "DOCSGPT-2025-001" in out
assert "DOCSGPT-2025-002" not in out