feat: FastAPI console with dashboard, Pulp client, healthz (stages 0-1)
- config: PULP_* env + 사내 CA(verify=) 대응 - pulp_client: Basic Auth get/post/patch, status/list_repos/get_version - BFF 라우트: GET / (대시보드), GET /repos (조각), GET /healthz - views: Pulp JSON → 표시 모델 (GPG 검증 배지 3단계, 요약 집계) - HTMX + Jinja 템플릿, vendored pico.css/htmx.js (CDN 의존 0) - 브라우저는 Pulp 직접 호출 안 함 — 모두 BFF 경유 (스펙 §2) - 테스트: pulp_client(respx), 라우트(dependency_overrides), views 순수함수 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
18
tests/conftest.py
Normal file
18
tests/conftest.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
PULP_TEST_URL = "http://pulp.test"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _settings_env(monkeypatch):
|
||||
"""모든 테스트를 알려진 Pulp base_url 로 고정하고 settings 캐시를 초기화."""
|
||||
monkeypatch.setenv("PULP_BASE_URL", PULP_TEST_URL)
|
||||
monkeypatch.setenv("PULP_USERNAME", "admin")
|
||||
monkeypatch.setenv("PULP_PASSWORD", "secret")
|
||||
monkeypatch.setenv("PULP_VERIFY_TLS", "false")
|
||||
monkeypatch.setenv("PULP_CA_FILE", "")
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
73
tests/test_dashboard.py
Normal file
73
tests/test_dashboard.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app, get_pulp_client
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
REPO = {
|
||||
"pulp_href": "/pulp/api/v3/repositories/rpm/rpm/abc-123/",
|
||||
"name": "rocky9-baseos",
|
||||
"latest_version_href": "/pulp/api/v3/repositories/rpm/rpm/abc-123/versions/2/",
|
||||
"repo_config": {"gpgcheck": 1, "repo-gpgcheck": 1},
|
||||
}
|
||||
VERSION = {
|
||||
"pulp_created": "2026-06-01T00:00:00Z",
|
||||
"content_summary": {"present": {"rpm.package": {"count": 1500}}},
|
||||
}
|
||||
|
||||
|
||||
class _FakePulp:
|
||||
def __init__(self, repos=None, version=None, fail=False):
|
||||
self._repos = repos or []
|
||||
self._version = version
|
||||
self._fail = fail
|
||||
|
||||
def list_repos(self):
|
||||
if self._fail:
|
||||
raise httpx.ConnectError("connection refused")
|
||||
return self._repos
|
||||
|
||||
def get_version(self, href):
|
||||
return self._version
|
||||
|
||||
|
||||
def _use(fake):
|
||||
app.dependency_overrides[get_pulp_client] = lambda: fake
|
||||
|
||||
|
||||
def teardown_function():
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_dashboard_lists_repos():
|
||||
_use(_FakePulp(repos=[REPO], version=VERSION))
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
assert "rocky9-baseos" in body
|
||||
assert "v2" in body
|
||||
assert "1500" in body
|
||||
assert "검증됨" in body # GPG pass 배지
|
||||
|
||||
|
||||
def test_repos_fragment_standalone():
|
||||
_use(_FakePulp(repos=[REPO], version=VERSION))
|
||||
resp = client.get("/repos")
|
||||
assert resp.status_code == 200
|
||||
assert 'id="repo-list"' in resp.text
|
||||
assert "rocky9-baseos" in resp.text
|
||||
|
||||
|
||||
def test_dashboard_empty():
|
||||
_use(_FakePulp(repos=[]))
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "등록된 저장소가 없습니다" in resp.text
|
||||
|
||||
|
||||
def test_dashboard_shows_error_when_pulp_down():
|
||||
_use(_FakePulp(fail=True))
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200 # 페이지는 뜨고 에러 메시지를 보여줌
|
||||
assert "불러오지 못했습니다" in resp.text
|
||||
36
tests/test_healthz.py
Normal file
36
tests/test_healthz.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app, get_pulp_client
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
class _FakeOnline:
|
||||
def status(self):
|
||||
return {"online": True}
|
||||
|
||||
|
||||
class _FakeDown:
|
||||
def status(self):
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
|
||||
def test_healthz_online():
|
||||
app.dependency_overrides[get_pulp_client] = lambda: _FakeOnline()
|
||||
try:
|
||||
resp = client.get("/healthz")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert resp.status_code == 200
|
||||
assert "연결됨" in resp.text
|
||||
|
||||
|
||||
def test_healthz_offline_when_pulp_down():
|
||||
app.dependency_overrides[get_pulp_client] = lambda: _FakeDown()
|
||||
try:
|
||||
resp = client.get("/healthz")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert resp.status_code == 503
|
||||
assert "연결 실패" in resp.text
|
||||
39
tests/test_pulp_client.py
Normal file
39
tests/test_pulp_client.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.config import get_settings, tls_verify
|
||||
from app.pulp_client import PulpClient
|
||||
from tests.conftest import PULP_TEST_URL
|
||||
|
||||
|
||||
def test_tls_verify_prefers_ca_file(monkeypatch):
|
||||
monkeypatch.setenv("PULP_CA_FILE", "/etc/ssl/boknet-ca.pem")
|
||||
monkeypatch.setenv("PULP_VERIFY_TLS", "false")
|
||||
get_settings.cache_clear()
|
||||
assert tls_verify(get_settings()) == "/etc/ssl/boknet-ca.pem"
|
||||
|
||||
|
||||
def test_tls_verify_falls_back_to_bool():
|
||||
# conftest: CA 비어있음, VERIFY_TLS=false
|
||||
assert tls_verify(get_settings()) is False
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_status_returns_json():
|
||||
route = respx.get(f"{PULP_TEST_URL}/pulp/api/v3/status/").mock(
|
||||
return_value=httpx.Response(200, json={"online": True, "versions": []})
|
||||
)
|
||||
with PulpClient() as pulp:
|
||||
data = pulp.status()
|
||||
assert route.called
|
||||
assert data == {"online": True, "versions": []}
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_get_raises_on_http_error():
|
||||
respx.get(f"{PULP_TEST_URL}/pulp/api/v3/status/").mock(
|
||||
return_value=httpx.Response(503)
|
||||
)
|
||||
with PulpClient() as pulp, pytest.raises(httpx.HTTPStatusError):
|
||||
pulp.status()
|
||||
72
tests/test_views.py
Normal file
72
tests/test_views.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from app import views
|
||||
|
||||
|
||||
def test_gpg_status_pass():
|
||||
repo = {"repo_config": {"gpgcheck": 1, "repo-gpgcheck": 1}}
|
||||
assert views.gpg_status(repo)["level"] == "pass"
|
||||
|
||||
|
||||
def test_gpg_status_warn_when_partial():
|
||||
repo = {"repo_config": {"gpgcheck": 1, "repo-gpgcheck": 0}}
|
||||
assert views.gpg_status(repo)["level"] == "warn"
|
||||
|
||||
|
||||
def test_gpg_status_unset_when_no_config():
|
||||
assert views.gpg_status({})["level"] == "unset"
|
||||
assert views.gpg_status({"repo_config": {}})["level"] == "unset"
|
||||
|
||||
|
||||
def test_version_number_parses_trailing_int():
|
||||
assert views.version_number("/pulp/api/v3/repositories/rpm/rpm/x/versions/7/") == 7
|
||||
assert views.version_number(None) is None
|
||||
assert views.version_number("/no/number/here/") is None
|
||||
|
||||
|
||||
def test_package_count_sums_present_counts():
|
||||
version = {
|
||||
"content_summary": {
|
||||
"present": {
|
||||
"rpm.package": {"count": 1500},
|
||||
"rpm.advisory": {"count": 42},
|
||||
}
|
||||
}
|
||||
}
|
||||
assert views.package_count(version) == 1542
|
||||
assert views.package_count(None) is None
|
||||
assert views.package_count({}) == 0
|
||||
|
||||
|
||||
def test_build_repo_view_combines_repo_and_version():
|
||||
repo = {
|
||||
"pulp_href": "/pulp/api/v3/repositories/rpm/rpm/abc-123/",
|
||||
"name": "rocky9-baseos",
|
||||
"latest_version_href": "/pulp/api/v3/repositories/rpm/rpm/abc-123/versions/2/",
|
||||
"repo_config": {"gpgcheck": 1, "repo-gpgcheck": 1},
|
||||
}
|
||||
version = {
|
||||
"pulp_created": "2026-06-01T00:00:00Z",
|
||||
"content_summary": {"present": {"rpm.package": {"count": 1500}}},
|
||||
}
|
||||
view = views.build_repo_view(repo, version)
|
||||
assert view["name"] == "rocky9-baseos"
|
||||
assert view["uuid"] == "abc-123"
|
||||
assert view["latest_version"] == 2
|
||||
assert view["package_count"] == 1500
|
||||
assert view["last_sync"] == "2026-06-01T00:00:00Z"
|
||||
assert view["gpg"]["level"] == "pass"
|
||||
assert view["deployed_version"] is None # 단계1 미구현
|
||||
|
||||
|
||||
def test_summarize_counts():
|
||||
repo_views = [
|
||||
{"gpg": {"level": "pass"}, "package_count": 100},
|
||||
{"gpg": {"level": "warn"}, "package_count": 50},
|
||||
{"gpg": {"level": "pass"}, "package_count": None},
|
||||
]
|
||||
summary = views.summarize(repo_views)
|
||||
assert summary == {
|
||||
"total": 3,
|
||||
"syncing": 0,
|
||||
"gpg_pass": 2,
|
||||
"total_packages": 150,
|
||||
}
|
||||
Reference in New Issue
Block a user