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

@ -0,0 +1,70 @@
# 008 — `source_name`/`source_id`/`source_prompt` accept any JSON type and get silently mangled into `repr()` strings
**Status:** Fixed
Follow-up to the #007 handover, which specifically asked to probe other
wrong-typed `PATCH` fields beyond `tags` now that #005 is fixed.
## Description
Unlike `tags`, `metadata`, and `tag_colors` (all now type-checked, `tags`
since #005), `source_name`, `source_id`, and `source_prompt` accept any
JSON type — object, array, number — on both `POST` (`source_fields()` in
`views.py`) and `PATCH` (`ArtifactDetailView.patch`). Nothing validates
they're strings before assignment.
`source_name`/`source_id` are `CharField`, `source_prompt` is `TextField`.
Django's `Field.get_prep_value()` silently coerces non-string input via
plain `str(value)` at save time — which for a `dict`/`list` produces
**Python `repr()` syntax, not JSON**: `{"a": 1}` becomes the *string*
`"{'a': 1}"` (single quotes, unparseable as JSON). This is lossy and
irreversible — there's no way to recover the original structured value.
**Worse: the API response is internally inconsistent.** Immediately after
a `POST`/`PATCH` with a non-string value, the response echoes back the
*raw, pre-save Python object* (still a real JSON object/array in the
response body) — but a subsequent `GET` of the same artifact returns the
mangled `repr()` string instead. Same request, two different apparent
types for the same field depending only on *when* you read it.
Reproduced:
```bash
# PATCH source_name with an int, source_id with a list, source_prompt with a dict
curl -X PATCH .../api/artifacts/a74de286 -d '{"source_name": 12345}' ...
# -> 200, response shows "source_name": 12345 (int)
curl .../api/artifacts/a74de286 # fresh GET, session auth
# -> "source_name": "12345" (string)
# Actual DB values (confirmed via shell):
# source_name = '12345'
# source_id = "['a', 'b']" <- repr() of the list, not JSON
# source_prompt = "{'nested': 'object'}" <- repr() of the dict, not JSON
```
Also reproduced identically on `POST` (create), not just `PATCH` — a
freshly created artifact's immediate response shows
`"source_name": {"a": 1}` (real object) even though what's stored (and
what any later `GET` returns) is the mangled string form.
## Suggested Fix
Add a type check for `source_name`, `source_id`, and `source_prompt`
(must be `str` or absent/null) alongside the existing `tags`/`metadata`/
`tag_colors` checks in `validate_source_fields` (create path) and
`ArtifactDetailView.patch` (update path) — return `400` naming the field,
consistent with #005's approach for `tags`. This also removes the
response/DB inconsistency, since invalid types would never reach `save()`
in the first place.
## Resolution
Added `validate_string_fields(data, fields)` in `artifacts/views.py`,
rejecting any non-`None`, non-`str` value with `400`. Called from
`validate_source_fields` (covers `POST`, both text and binary paths) and
directly in `ArtifactDetailView.patch` for `source_name`, `source_id`,
`source_prompt` — matching #005's approach for `tags`. Invalid values never
reach `save()`, so the response/DB inconsistency the issue described can no
longer occur. Verified live against the running dev instance and with new
tests in `tests/api/test_artifacts.py`.