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