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
145 lines
5.5 KiB
Python
145 lines
5.5 KiB
Python
"""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
|