v0.3.2: add GET /api/artifacts/{id}/raw for content read-back

Metadata (GET /api/artifacts/{id}) stays metadata-only; the full content now
has its own endpoint so an agent can verify exactly what it posted.

- New ArtifactRawView at /api/artifacts/{id}/raw: bearer-or-session auth,
  owner-scoped. Serves exact source text (markdown/html_full/json/csv), file
  bytes (image/pdf/binary), or 404 for render-only kinds (html_fragment/deck).
  Reuses raw_artifact_path/safe_artifact_path.
- serialize_artifact exposes api_raw_url; /api/capabilities advertises the
  route. (The old /a/{id}?raw= viewer stays session-only for browsers.)
- Docs + tests (exact content, owner-scoping, 403 unauth, 404 render-only).
  Suite: 136 passing; verified live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-04 15:20:37 +01:00
parent 2b901f7844
commit f6f004c689
6 changed files with 102 additions and 15 deletions

View file

@ -253,6 +253,46 @@ class ArtifactAPITestCase(APITestCase):
response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
self.assertEqual(response.status_code, 403)
# -- raw content (GET {id}/raw) ---------------------------------------
def _raw_body(self, response):
return b"".join(response.streaming_content).decode()
def test_raw_returns_exact_content_with_bearer(self):
create = self.create_artifact(content="# Exact\n\nverify me")
artifact_id = create.data["id"]
# metadata detail advertises the raw endpoint
self.assertTrue(create.data["api_raw_url"].endswith(f"/api/artifacts/{artifact_id}/raw"))
response = self.client.get(
reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.token)
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8")
# Content round-trips faithfully; normalize newlines since the stored
# sidecar is written in the host's text mode (CRLF on Windows dev, LF on
# the Linux deploy).
self.assertEqual(self._raw_body(response).replace("\r\n", "\n"), "# Exact\n\nverify me")
def test_raw_requires_auth(self):
artifact_id = self.create_artifact().data["id"]
response = self.client.get(reverse("artifact-raw-api", args=[artifact_id]))
self.assertEqual(response.status_code, 403)
def test_raw_owner_scoped(self):
artifact_id = self.create_artifact().data["id"]
response = self.client.get(
reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.other_token)
)
self.assertEqual(response.status_code, 404)
def test_raw_not_available_for_render_only_kind(self):
artifact_id = self.create_artifact(kind="html_fragment", content="<p>hi</p>").data["id"]
response = self.client.get(
reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.token)
)
self.assertEqual(response.status_code, 404)
def test_patch_updates_title_and_tags(self):
artifact_id = self.create_artifact().data["id"]
response = self.client.patch(