Add json and csv artifact kinds with interactive viewers
Gap 1 from planning/2026-09-02-artifact_kind_gaps_for_frequent_agent_output.md. Chose new first-class kinds over riding on html_fragment, resolving that doc's open design question in favor of filtering support, raw-source preservation (like markdown's .md sidecar), and server-owned validation. - json: validated with json.loads at post time (400 on invalid JSON), rendered with renderjson (collapsible tree), vendored locally. - csv: parsed server-side with the stdlib csv module into columns/rows and handed to Grid.js as plain data - no client-side CSV-parsing plugin needed, keeping normalization server-owned. Vendored locally (MIT). - Both preserve their original text as a sidecar file, generalizing what was previously markdown-only handling (storage.py's SIDECAR_SUFFIXES) so disk-size accounting, eviction cleanup, and ?raw=1 all cover json/csv too. - Raw JSON is embedded in a <script type="application/json"> block with `</script` occurrences escaped (safe because JSON's \/ escape round-trips correctly) - verified with a live </script>-injection payload against the actual running server, not just unit tests. New migration (0012) for the KIND_CHOICES change - SQLite doesn't enforce choices at the schema level, but Django's migration state tracks it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
af9ecf28a9
commit
e5d2f192df
12 changed files with 476 additions and 18 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
|
@ -369,3 +370,60 @@ class ArtifactAPITestCase(APITestCase):
|
|||
self.assertIn('class="codehilite"', stored_html)
|
||||
self.assertIn('<span class="k">def</span>', stored_html)
|
||||
self.assertIn('href="http://testserver/static/themes/pygments.css"', stored_html)
|
||||
|
||||
# -- json kind ------------------------------------------------------------
|
||||
|
||||
def test_create_json_artifact(self):
|
||||
response = self.create_artifact(kind="json", content='{"a": 1, "b": [1, 2, 3]}')
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
artifact = Artifact.objects.get(pk=response.data["id"])
|
||||
stored_html = (self.artifacts_root / artifact.path).read_text(encoding="utf-8")
|
||||
self.assertIn('renderjson/renderjson.js', stored_html)
|
||||
self.assertIn('type="application/json"', stored_html)
|
||||
|
||||
# raw sidecar preserves the exact original JSON text
|
||||
raw_path = (self.artifacts_root / artifact.path).with_suffix(".json")
|
||||
self.assertEqual(raw_path.read_text(encoding="utf-8"), '{"a": 1, "b": [1, 2, 3]}')
|
||||
|
||||
def test_create_json_artifact_rejects_invalid_json(self):
|
||||
response = self.create_artifact(kind="json", content="{not valid json")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_json_artifact_escapes_script_close_tag_safely(self):
|
||||
payload = json.dumps({"value": "</script><script>alert(1)</script>"})
|
||||
response = self.create_artifact(kind="json", content=payload)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
artifact = Artifact.objects.get(pk=response.data["id"])
|
||||
stored_html = (self.artifacts_root / artifact.path).read_text(encoding="utf-8")
|
||||
|
||||
# The page has exactly 3 real <script> elements (renderjson lib, the
|
||||
# embedded data block, the init snippet). If the injected payload's
|
||||
# "</script" wasn't escaped, it would add a 4th closing tag here.
|
||||
self.assertEqual(stored_html.count("</script>"), 3)
|
||||
|
||||
# The escaped content must still round-trip to the exact original value.
|
||||
start = stored_html.index('id="daybook-data">') + len('id="daybook-data">')
|
||||
end = stored_html.index("</script>", start)
|
||||
embedded = stored_html[start:end]
|
||||
self.assertEqual(json.loads(embedded)["value"], "</script><script>alert(1)</script>")
|
||||
|
||||
# -- csv kind ---------------------------------------------------------------
|
||||
|
||||
def test_create_csv_artifact(self):
|
||||
response = self.create_artifact(kind="csv", content="name,score\nalice,10\nbob,7")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
artifact = Artifact.objects.get(pk=response.data["id"])
|
||||
stored_html = (self.artifacts_root / artifact.path).read_text(encoding="utf-8")
|
||||
self.assertIn("gridjs/gridjs.umd.js", stored_html)
|
||||
self.assertIn('"columns": ["name", "score"]', stored_html)
|
||||
self.assertIn('["alice", "10"]', stored_html)
|
||||
|
||||
raw_path = (self.artifacts_root / artifact.path).with_suffix(".csv")
|
||||
self.assertEqual(raw_path.read_text(encoding="utf-8"), "name,score\nalice,10\nbob,7")
|
||||
|
||||
def test_create_csv_artifact_with_header_only(self):
|
||||
response = self.create_artifact(kind="csv", content="name,score")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue