chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user