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

68 lines
3.2 KiB
Markdown

# 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
```bash
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.