daybook/issues/003-model-max-length-silently-unenforced.md
peter 07328a3412 Fix issues #002-#006 found by the LLM test harness
- #006 (high, data loss): make_room_for() no longer deletes unkept
  artifacts on a doomed request - checks whether eviction can possibly
  succeed before deleting anything. Also fixed a related latent bug found
  while testing: the function returned evicted Artifact objects after
  calling .delete() on them, so their .id was always None (Django clears
  pk on delete); now returns ids captured beforehand.
- #003: added server-side field-length validation (source/title/
  source_name/source_id/theme) on POST and PATCH, matching the model's
  own max_length limits, which were previously unenforced on the ingest
  path entirely.
- #004: ported daybook_post.py's content-shape validation
  (html_fragment/html_full/deck/markdown) to the server, closing the gap
  for any client that doesn't go through the helper script.
- #005: PATCH now rejects wrong-typed `tags` (previously silently wiped
  existing tags) and rejects immutable fields (content/source/kind/day)
  with 400 instead of silently no-op'ing them.
- #002: deleted the misleading skills/daybook-posting/.env.example (a
  zero-dependency script that never reads .env files) and clarified
  SKILL.md/docs/llm_posting_helper.md that these must be real
  process/shell env vars.

Adds tests/unit/ for storage-layer tests. 46 tests passing (was 25).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 13:38:59 +01:00

3.2 KiB

003 — CharField(max_length=...) limits are silently unenforced on ingest

Status: Fixed

Description

artifacts/models.py declares length caps on several Artifact fields — source (80), title (200), source_name (120), source_id (120), theme (40) — but nothing on the ingest path enforces them. views.py's create_text_artifact/create_binary_artifact call Artifact.objects.create(...) directly, which does not run Django's full_clean()/validators. SQLite (the DB in both dev and production per docs/deployment.md line 197) also doesn't enforce VARCHAR(n) length as a hard constraint on INSERT, so oversized values are written and read back verbatim with no error at any layer.

Confirmed against the local test account:

  1. POST /api/artifacts with a 250-character title (limit 200) → 201, title stored and returned in full, uncut.
  2. POST /api/artifacts with a 150-character source (limit 80) → 201, source stored in full. Because source_name defaults to source when omitted (source_fields() in views.py), this also silently blew past source_name's own 120-char limit in the same request.

For contrast, other validation on the same endpoint works correctly and returns a clean 400 — e.g. an invalid tag_colors hex value ({"tag_colors": {"urgent": "notacolor"}} → 400 with a helpful message). So the endpoint clearly has a validation convention; these five fields just aren't part of it.

Practically: a title long enough could break feed-row layout in the UI; source feeds directly into the on-disk storage path (artifacts_store/{source}/{day}/...) and the sidebar/label system, so an extreme value there has a larger blast radius than just cosmetics.

Reproduction

curl -X POST http://127.0.0.1:8001/api/artifacts \
  -H "Authorization: Bearer <test-account-token>" \
  -H "Content-Type: application/json" \
  -d "{\"source\":\"edge-case-test\",\"title\":\"$(python3 -c 'print("A"*250)')\",\"kind\":\"markdown\",\"content\":\"x\"}"
# -> 201, title returned uncut at 250 chars

Suggested Fix

Add explicit length checks alongside the existing required-field/kind checks in ArtifactListCreateView.post and ArtifactDetailView.patch (and the two create_*_artifact helpers), returning 400 with a message naming the field and its limit — consistent with how tag_colors and source_type are already handled. A shared validate_field_lengths(data) helper (mirroring validate_source_fields) covering source, title, source_name, source_id, theme would keep it in one place.

Resolution

Added validate_field_lengths(data, fields) in artifacts/views.py, called from validate_source_fields (covers source, title, source_name, source_id, theme on both text and binary POST, since both paths already routed through that function) and directly in ArtifactDetailView.patch (covers title, source_name, source_id — the only length-limited fields PATCH can touch). Returns 400 naming the field and its limit, matching the existing tag_colors/source_type convention. Tests added in tests/api/test_artifacts.py covering oversized title/source on create, an exact-limit boundary case, and oversized title on patch.