Reject non-string source_name/source_id/source_prompt (issue #008)

These accepted any JSON type on both POST and PATCH, silently mangled
into Python repr() strings at save time (a dict became the literal text
"{'a': 1}", unparseable as JSON) - and the immediate response echoed the
raw pre-save value, while a later GET returned the mangled one, an
internally inconsistent API.

Added validate_string_fields(), rejecting non-string values with 400
before they reach save(), matching #005's approach for tags. Verified live
and with new tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-02 15:27:07 +01:00
parent d1b6a19eb3
commit 09ea0a8b4f
3 changed files with 117 additions and 0 deletions

View file

@ -237,6 +237,9 @@ class ArtifactDetailView(APIView):
length_error = validate_field_lengths(data, ["title", "source_name", "source_id"])
if length_error:
return length_error
string_error = validate_string_fields(data, ["source_name", "source_id", "source_prompt"])
if string_error:
return string_error
if "title" in data:
if not data["title"]:
return Response({"detail": "title cannot be blank"}, status=400)
@ -860,6 +863,19 @@ def validate_field_lengths(data, fields):
return None
def validate_string_fields(data, fields):
"""Rejects non-string JSON types (object/array/number) instead of letting
them reach save(), where Django's CharField/TextField silently coerce via
str() - which for a dict/list produces Python repr() syntax, not JSON,
and is irreversible.
"""
for field in fields:
value = data.get(field)
if value is not None and not isinstance(value, str):
return Response({"detail": f"{field} must be a string"}, status=400)
return None
def validate_content_shape(kind, content):
lower = (content or "").lower()
if kind == Artifact.DECK and "<section" not in lower:
@ -889,6 +905,9 @@ def validate_source_fields(data):
length_error = validate_field_lengths(data, ["source", "title", "source_name", "source_id", "theme"])
if length_error:
return length_error
string_error = validate_string_fields(data, ["source_name", "source_id", "source_prompt"])
if string_error:
return string_error
if source_type := data.get("source_type"):
if source_type not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}:
return Response({"detail": "source_type must be agent, person, or system"}, status=400)