canned-prompts/service/tests/test_health.py
tegwick a7e12d5e04 CANP-WP-0006 T04: publish API
POST /packages takes {registry, source, files}, where files is the same shape
GET /archives returns — so an archive round-trips into a publish without
translation and a mirror is a GET followed by a POST. A test verifies the
round-trip by digest rather than asserting it.

Validation is the reference implementation's, applied to the posted files
materialized in a temporary directory. Only reserved paths and
manifest-referenced files are stored (section 2), path traversal is refused,
identical re-publishes are accepted, and different content under the same
id@version is 409 (section 17).

The identity mechanism, stated plainly rather than implied: a single shared
bearer token proving the caller is the operator of this service. It is not
per-publisher identity — every token holder is indistinguishable — and auth.py
says so where someone might otherwise assume more.

With no token configured the service is read-only. That is the correct default
rather than an inconvenience: section 20.1 asks a registry to refuse
publication into a closed namespace it does not consider the publisher to own,
and an unauthenticated service considers nobody to own anything.

Namespace claims live in namespace_claims (migration 0002) and are enforced
here, which a filesystem registry cannot do at all — but only as precisely as
the identity allows. A closed namespace is protected from anonymous callers; it
cannot be attributed among several publishers. Per-publisher identity is
deferred and is the main thing between this and a registry several people can
publish to.

Migration hygiene found while adding 0002: autogenerate proposed an ALTER
COLUMN TYPE on package_files.package_version_id, because the foreign key's type
was left to inference and compared as a variant against a reflected plain type.
SQLite cannot alter a column type, so 0002 failed halfway — table created,
revision unstamped, the partially-applied state that is worst to debug later.
Fixed at the cause: the column is typed explicitly, and 0001 was corrected
rather than patched over, which is legitimate only because it has never run
outside this repo's tests. alembic check now reports no drift.

Health tests now compute the expected migration head from the script directory
instead of hardcoding it, so adding a migration cannot fail them spuriously.

Service tests 22 -> 33; reference unaffected at 99.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
2026-09-06 20:30:00 +02:00

102 lines
3.6 KiB
Python

"""Health surface.
The point of these tests is the negative cases. An endpoint that returns 200
under every condition tells an orchestrator nothing, and the failure is silent
exactly when it matters.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from alembic.script import ScriptDirectory
from fastapi.testclient import TestClient
from canned_prompts_service.api import create_app
from canned_prompts_service.db import check_readiness, make_engine
from canned_prompts_service.settings import Settings
ROOT = Path(__file__).resolve().parents[1]
def expected_head() -> str:
"""Computed, not hardcoded: a new migration must not fail these tests."""
config = Config(str(ROOT / "alembic.ini"))
config.set_main_option("script_location", str(ROOT / "migrations"))
return ScriptDirectory.from_config(config).get_current_head()
def migrated_url(tmp_path: Path) -> str:
url = f"sqlite:///{tmp_path / 'svc.db'}"
config = Config(str(ROOT / "alembic.ini"))
config.set_main_option("script_location", str(ROOT / "migrations"))
config.set_main_option("sqlalchemy.url", url)
command.upgrade(config, "head")
return url
@pytest.fixture()
def ready_client(tmp_path: Path) -> TestClient:
url = migrated_url(tmp_path)
return TestClient(create_app(Settings(database_url=url), make_engine(url)))
def test_healthz_is_up_without_a_database() -> None:
"""Liveness must not depend on the database, or a DB blip restarts pods."""
client = TestClient(create_app(Settings(), None))
assert client.get("/healthz").json() == {"status": "ok"}
def test_readyz_fails_without_a_database() -> None:
client = TestClient(create_app(Settings(), None))
response = client.get("/readyz")
assert response.status_code == 503
assert response.json()["ready"] is False
assert "no database" in response.json()["detail"]
def test_readyz_fails_when_the_database_is_unreachable(tmp_path: Path) -> None:
engine = make_engine("sqlite:////nonexistent/dir/does-not-exist.db")
client = TestClient(create_app(Settings(database_url="x"), engine))
response = client.get("/readyz")
assert response.status_code == 503
assert response.json()["ready"] is False
def test_readyz_fails_when_the_schema_is_not_migrated(tmp_path: Path) -> None:
"""Reachable but unmigrated is not ready — it would 500 on the first query."""
engine = make_engine(f"sqlite:///{tmp_path / 'empty.db'}")
client = TestClient(create_app(Settings(database_url="x"), engine))
response = client.get("/readyz")
assert response.status_code == 503
assert response.json()["detail"] == "schema not migrated"
def test_readyz_reports_the_migration_when_ready(ready_client: TestClient) -> None:
body = ready_client.get("/readyz").json()
assert body["ready"] is True
assert body["migration"] == expected_head()
def test_state_health_matches_the_fleet_shape(ready_client: TestClient) -> None:
body = ready_client.get("/state/health").json()
assert body["status"] == "ok"
assert body["service"] == "canned-prompts"
assert body["db"] == "connected"
assert body["migration"] == expected_head()
def test_state_health_degrades_rather_than_lying() -> None:
client = TestClient(create_app(Settings(), None))
response = client.get("/state/health")
assert response.status_code == 503
assert response.json()["status"] == "degraded"
def test_settings_have_no_database_fallback() -> None:
"""Falling back to a local database when misconfigured hides the mistake."""
assert Settings().database_url == ""
assert Settings().configured is False