decomposer: generate deliverable files for Define the service contract and project architecture for the FastAPI endpoint monitoring service.; Implement the typed monitor CRUD API and concurrency-safe in-memory state according to the service design.; Implement secure on-demand endpoint checks with status updates, latency measurement, robust error handling, and redacted structured logs.; Add operational API endpoints and environment-driven runtime configuration to the monitoring service.; Create automated tests for the monitoring service.; Package the service with Docker and developer documentation.; Validate the complete project.
Some checks failed
ci / validate (push) Has been cancelled

This commit is contained in:
2026-08-09 15:46:55 +00:00
parent 9453784868
commit ccb748a367

View File

@@ -1,72 +1,92 @@
import asyncio
import json import json
import logging import logging
import socket
import httpx
import pytest import pytest
from fastapi.testclient import TestClient
from app.checker import SafeResolver, UnsafeTargetError from app.checker import EndpointChecker
from app.logging_config import JsonFormatter, redact_url from app.config import Settings
from app.models import CurrentStatus, MonitorInput, State from app.logging import JsonFormatter
from app.store import MonitorStore, StaleCheckError from app.main import app
from app.models import State
from app.security import DestinationRejected, redacted_url
def test_resolver_blocks_private_answer(monkeypatch: pytest.MonkeyPatch) -> None: async def public_resolver(host: str, port: int) -> list[str]:
def private(*args: object, **kwargs: object) -> list[tuple]: return ["93.184.216.34"]
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 80))]
monkeypatch.setattr(socket, "getaddrinfo", private)
with pytest.raises(UnsafeTargetError):
asyncio.run(SafeResolver().validate_url("http://attacker.example/path"))
def test_resolver_accepts_only_public_answers(monkeypatch: pytest.MonkeyPatch) -> None: async def private_resolver(host: str, port: int) -> list[str]:
def public(*args: object, **kwargs: object) -> list[tuple]: return ["127.0.0.1"]
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443))]
monkeypatch.setattr(socket, "getaddrinfo", public)
results = asyncio.run(SafeResolver().resolve("example.com", 443, socket.AF_UNSPEC))
assert results[0]["host"] == "93.184.216.34"
def test_redirect_destination_is_revalidated() -> None: @pytest.mark.asyncio
resolver = SafeResolver() async def test_success_down_and_transport_error() -> None:
calls: list[str] = [] async def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/ok":
return httpx.Response(204)
if request.url.path == "/down":
return httpx.Response(503)
raise httpx.ConnectError("secret host detail", request=request)
async def validate(url: str) -> None: worker = EndpointChecker(Settings(), public_resolver, httpx.MockTransport(handler))
calls.append(url) assert (await worker.check("1", "https://example.com/ok?token=x")).state is State.UP
if len(calls) == 2: assert (await worker.check("1", "https://example.com/down")).state is State.DOWN
raise UnsafeTargetError failed = await worker.check("1", "https://example.com/fail")
assert failed.state is State.ERROR
resolver.validate_url = validate # type: ignore[method-assign] assert failed.error == "outbound request failed: ConnectError"
async def redirects() -> None:
await resolver.validate_url("https://public.example")
with pytest.raises(UnsafeTargetError):
await resolver.validate_url("http://127.0.0.1/admin")
asyncio.run(redirects())
assert calls == ["https://public.example", "http://127.0.0.1/admin"]
def test_url_and_structured_log_redaction() -> None: @pytest.mark.asyncio
secret = "https://user:pass@example.com/path?token=secret#fragment" async def test_private_dns_and_private_redirect_are_blocked() -> None:
assert redact_url(secret) == "https://example.com/path" worker = EndpointChecker(Settings(), private_resolver, httpx.MockTransport(lambda r: httpx.Response(200)))
formatter = JsonFormatter() with pytest.raises(DestinationRejected):
record = logging.LogRecord("test", logging.INFO, "", 0, "event", (), None) await worker.check("1", "http://example.com")
record.fields = {"url": secret, "monitor_id": "abc"} # type: ignore[attr-defined]
payload = formatter.format(record) calls = 0
assert "secret" not in payload and "pass" not in payload
assert json.loads(payload)["url"] == "https://example.com/path" async def changing_resolver(host: str, port: int) -> list[str]:
return ["127.0.0.1"] if host == "internal.test" else ["93.184.216.34"]
async def redirect(request: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
return httpx.Response(302, headers={"location": "http://internal.test/admin"})
worker = EndpointChecker(Settings(), changing_resolver, httpx.MockTransport(redirect))
with pytest.raises(DestinationRejected):
await worker.check("1", "https://example.com/start")
assert calls == 1
def test_stale_check_cannot_overwrite_new_url() -> None: def test_api_check_updates_status_and_rejection(client: TestClient) -> None:
async def scenario() -> None: created = client.post(
store = MonitorStore(5) "/v1/monitors", json={"name": "site", "url": "https://example.com/ok"}
item = await store.create(MonitorInput(name="x", url="https://example.com")) ).json()
await store.replace( app.state.checker = EndpointChecker(
item.id, MonitorInput(name="x", url="https://example.org") Settings(), public_resolver, httpx.MockTransport(lambda r: httpx.Response(200))
) )
with pytest.raises(StaleCheckError): response = client.post(f"/v1/monitors/{created['id']}/check")
await store.publish_status( assert response.status_code == 200
item.id, str(item.url), CurrentStatus(state=State.UP) assert response.json()["state"] == "up"
assert client.get(f"/v1/monitors/{created['id']}/status").json()["status_code"] == 200
app.state.checker = EndpointChecker(
Settings(), private_resolver, httpx.MockTransport(lambda r: httpx.Response(200))
) )
asyncio.run(scenario()) response = client.post(f"/v1/monitors/{created['id']}/check")
assert response.status_code == 400
assert client.get(f"/v1/monitors/{created['id']}/status").json()["state"] == "error"
def test_url_and_json_log_redaction() -> None:
safe = redacted_url("https://user:pass@example.com/a?token=secret#fragment")
assert safe == "https://example.com/a?<redacted>"
record = logging.LogRecord("x", logging.INFO, "", 0, "done", (), None)
record.url = safe
payload = json.loads(JsonFormatter().format(record))
serialized = json.dumps(payload)
assert "secret" not in serialized
assert "pass" not in serialized
assert payload["url"].endswith("?<redacted>")