canned-prompts/service/tests/test_publish_api.py

146 lines
5.5 KiB
Python
Raw Normal View History

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
"""Publish API (CANP-WP-0006 T04)."""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from canned_prompts_service.api import create_app
from canned_prompts_service.db import make_engine, make_session_factory
from canned_prompts_service.models import NamespaceClaim
from canned_prompts_service.settings import Settings
REPO = Path(__file__).resolve().parents[2]
TOKEN = "test-token"
AUTH = {"Authorization": f"Bearer {TOKEN}"}
def package_payload(name: str, registry: str = "local") -> dict:
"""Built from the archive shape, so an archive round-trips into a publish."""
source_dir = REPO / "examples" / name
import canned_prompts as cp
manifest = cp.validate_package(source_dir)
files = {
rel: {"text": (source_dir / rel).read_text(encoding="utf-8")}
for rel in sorted(cp.package_members(source_dir, manifest))
}
return {"registry": registry, "source": f"examples/{name}", "files": files}
@pytest.fixture()
def writable(db_url: str) -> TestClient:
settings = Settings(database_url=db_url, publish_token=TOKEN)
return TestClient(create_app(settings, make_engine(db_url)))
def test_unconfigured_service_refuses_writes(client: TestClient) -> None:
"""No publisher identity means the service cannot tell who is calling."""
response = client.post("/packages", json=package_payload("house-style"))
assert response.status_code == 503
assert "publishing is not configured" in response.json()["detail"]
def test_publish_requires_a_token(writable: TestClient) -> None:
response = writable.post("/packages", json=package_payload("house-style"))
assert response.status_code == 401
def test_wrong_token_is_refused(writable: TestClient) -> None:
response = writable.post(
"/packages",
json=package_payload("house-style"),
headers={"Authorization": "Bearer nope"},
)
assert response.status_code == 403
def test_publish_then_read_back(writable: TestClient) -> None:
response = writable.post("/packages", json=package_payload("house-style"), headers=AUTH)
assert response.status_code == 201
assert response.json()["reference"] == "local:practice/house-style@0.1.1"
body = writable.get("/packages/local:practice/house-style@0.1.1").json()
assert body["type"] == "fragment"
assert writable.get("/index").json()["count"] == 1
def test_archive_round_trips_into_a_publish(writable: TestClient) -> None:
"""What GET /archives returns is exactly what POST /packages accepts."""
writable.post("/packages", json=package_payload("house-style"), headers=AUTH)
archive = writable.get("/archives/practice/house-style@0.1.1").json()
republished = writable.post(
"/packages",
json={"registry": "mirror", "source": "round-trip", "files": archive["files"]},
headers=AUTH,
)
assert republished.status_code == 201
assert republished.json()["content_digest"] == archive["content_digest"]
def test_identical_republish_is_accepted(writable: TestClient) -> None:
payload = package_payload("house-style")
assert writable.post("/packages", json=payload, headers=AUTH).status_code == 201
assert writable.post("/packages", json=payload, headers=AUTH).status_code == 201
def test_different_content_under_the_same_version_is_a_conflict(writable: TestClient) -> None:
"""§ 17: a published id@version is immutable within a registry."""
payload = package_payload("house-style")
writable.post("/packages", json=payload, headers=AUTH)
payload["files"]["prompt.md"]["text"] += "\nsomething different\n"
response = writable.post("/packages", json=payload, headers=AUTH)
assert response.status_code == 409
assert "already exists with different content" in response.json()["detail"]
def test_invalid_package_is_rejected(writable: TestClient) -> None:
payload = package_payload("house-style")
payload["files"]["prompt.yaml"]["text"] = "format: canned-prompt/v9.9\nid: x/y\n"
response = writable.post("/packages", json=payload, headers=AUTH)
assert response.status_code == 400
def test_path_traversal_is_refused(writable: TestClient) -> None:
payload = package_payload("house-style")
payload["files"]["../escape.txt"] = {"text": "no"}
assert writable.post("/packages", json=payload, headers=AUTH).status_code == 400
def test_closed_namespace_refuses_a_publisher_it_cannot_attribute(
db_url: str, session: Session
) -> None:
"""§ 20.1 enforced rather than advised — as far as identity allows."""
session.add(
NamespaceClaim(
tenant="default", registry="local", namespace="practice",
policy="closed", owner="someone-else",
)
)
session.commit()
client = TestClient(
create_app(Settings(database_url=db_url, publish_token=TOKEN), make_engine(db_url))
)
response = client.post("/packages", json=package_payload("house-style"), headers=AUTH)
assert response.status_code == 403
assert "is closed" in response.json()["detail"]
def test_open_namespace_publishes(db_url: str, session: Session) -> None:
session.add(
NamespaceClaim(
tenant="default", registry="local", namespace="practice", policy="open"
)
)
session.commit()
client = TestClient(
create_app(Settings(database_url=db_url, publish_token=TOKEN), make_engine(db_url))
)
assert client.post(
"/packages", json=package_payload("house-style"), headers=AUTH
).status_code == 201