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:41:18 +00:00
parent e56b15b112
commit be68ab4589

View File

@@ -1,67 +1,72 @@
import asyncio
import json import json
import logging import logging
import socket
import httpx
import pytest import pytest
from monitor_service.checker import EndpointChecker, SecurityCheckError from app.checker import SafeResolver, UnsafeTargetError
from monitor_service.config import Settings from app.logging_config import JsonFormatter, redact_url
from app.models import CurrentStatus, MonitorInput, State
from app.store import MonitorStore, StaleCheckError
async def public_resolver(host: str, port: int) -> list[str]: def test_resolver_blocks_private_answer(monkeypatch: pytest.MonkeyPatch) -> None:
return ["93.184.216.34"] def private(*args: object, **kwargs: object) -> list[tuple]:
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"))
@pytest.mark.anyio def test_resolver_accepts_only_public_answers(monkeypatch: pytest.MonkeyPatch) -> None:
async def test_success_latency_and_query_redaction(caplog: pytest.LogCaptureFixture) -> None: def public(*args: object, **kwargs: object) -> list[tuple]:
transport = httpx.MockTransport(lambda request: httpx.Response(200, request=request)) return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443))]
async with httpx.AsyncClient(transport=transport) as client:
checker = EndpointChecker(client, Settings(), public_resolver) monkeypatch.setattr(socket, "getaddrinfo", public)
with caplog.at_level(logging.INFO, logger="monitor_service.checker"): results = asyncio.run(SafeResolver().resolve("example.com", 443, socket.AF_UNSPEC))
result = await checker.check("id", "https://example.com/path?token=very-secret") assert results[0]["host"] == "93.184.216.34"
assert result.status.state == "up"
assert result.status.http_status == 200
assert result.status.latency_ms is not None
assert "very-secret" not in caplog.text
event = json.loads(caplog.records[-1].message)
assert event["url"] == "https://example.com/path"
@pytest.mark.anyio def test_redirect_destination_is_revalidated() -> None:
async def test_transport_failure_maps_to_down() -> None: resolver = SafeResolver()
def fail(request: httpx.Request) -> httpx.Response: calls: list[str] = []
raise httpx.ConnectTimeout("late", request=request)
async with httpx.AsyncClient(transport=httpx.MockTransport(fail)) as client: async def validate(url: str) -> None:
result = await EndpointChecker(client, Settings(), public_resolver).check("id", "https://example.com") calls.append(url)
assert result.status.state == "down" if len(calls) == 2:
assert result.status.error == "ConnectTimeout" raise UnsafeTargetError
resolver.validate_url = validate # type: ignore[method-assign]
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"]
@pytest.mark.anyio def test_url_and_structured_log_redaction() -> None:
@pytest.mark.parametrize("address", ["127.0.0.1", "10.0.0.1", "169.254.169.254", "::1"]) secret = "https://user:pass@example.com/path?token=secret#fragment"
async def test_dns_private_addresses_are_blocked(address: str) -> None: assert redact_url(secret) == "https://example.com/path"
async def resolver(host: str, port: int) -> list[str]: formatter = JsonFormatter()
return [address] record = logging.LogRecord("test", logging.INFO, "", 0, "event", (), None)
record.fields = {"url": secret, "monitor_id": "abc"} # type: ignore[attr-defined]
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200))) as client: payload = formatter.format(record)
checker = EndpointChecker(client, Settings(), resolver) assert "secret" not in payload and "pass" not in payload
with pytest.raises(SecurityCheckError, match="non-public"): assert json.loads(payload)["url"] == "https://example.com/path"
await checker.check("id", "http://attacker.example")
@pytest.mark.anyio def test_stale_check_cannot_overwrite_new_url() -> None:
async def test_redirect_target_is_resolved_and_blocked() -> None: async def scenario() -> None:
seen: list[str] = [] store = MonitorStore(5)
item = await store.create(MonitorInput(name="x", url="https://example.com"))
async def resolver(host: str, port: int) -> list[str]: await store.replace(
seen.append(host) item.id, MonitorInput(name="x", url="https://example.org")
return ["127.0.0.1"] if host == "internal.example" else ["93.184.216.34"] )
with pytest.raises(StaleCheckError):
def redirect(request: httpx.Request) -> httpx.Response: await store.publish_status(
return httpx.Response(302, headers={"location": "http://internal.example/admin"}, request=request) item.id, str(item.url), CurrentStatus(state=State.UP)
)
async with httpx.AsyncClient(transport=httpx.MockTransport(redirect)) as client: asyncio.run(scenario())
with pytest.raises(SecurityCheckError):
await EndpointChecker(client, Settings(), resolver).check("id", "https://public.example")
assert seen == ["public.example", "internal.example"]