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>
This commit is contained in:
peter 2026-09-02 13:38:59 +01:00
parent c1ea5c7990
commit 07328a3412
13 changed files with 635 additions and 35 deletions

View file

@ -47,6 +47,9 @@ from .storage import (
TEXT_KINDS = {Artifact.HTML_FRAGMENT, Artifact.HTML_FULL, Artifact.MARKDOWN, Artifact.DECK}
# Affects storage layout, renderer choice, or feed ordering - never editable after create.
IMMUTABLE_PATCH_FIELDS = {"content", "source", "kind", "day"}
REMEMBER_ME_SESSION_AGE = 60 * 60 * 24 * 30 # 30 days
MAX_ASSET_BYTES = 50 * 1024 * 1024 # 50 MB per uploaded image
@ -112,6 +115,10 @@ class ArtifactListCreateView(APIView):
if source_error:
return source_error
content_error = validate_content_shape(kind, data["content"])
if content_error:
return content_error
artifact = create_text_artifact(data, request, user)
if isinstance(artifact, Response):
return artifact
@ -206,11 +213,22 @@ class ArtifactDetailView(APIView):
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
data = request.data
not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys())
if not_editable:
return Response(
{"detail": f"Not editable via PATCH: {', '.join(sorted(not_editable))}"},
status=400,
)
length_error = validate_field_lengths(data, ["title", "source_name", "source_id"])
if length_error:
return length_error
if "title" in data:
if not data["title"]:
return Response({"detail": "title cannot be blank"}, status=400)
artifact.title = data["title"]
if "tags" in data:
if not isinstance(data["tags"], (str, list)):
return Response({"detail": "tags must be a string or an array"}, status=400)
artifact.tags = normalize_tags(data["tags"])
if "source_prompt" in data:
artifact.source_prompt = data["source_prompt"] or ""
@ -691,7 +709,50 @@ def labels_with_colors(artifact, overrides=None):
return labels_with_colors_list([artifact.source, *artifact.tags], overrides)
FIELD_LENGTH_LIMITS = {
"source": 80,
"title": 200,
"source_name": 120,
"source_id": 120,
"theme": 40,
}
def validate_field_lengths(data, fields):
for field in fields:
value = data.get(field)
if value and len(str(value)) > FIELD_LENGTH_LIMITS[field]:
return Response(
{"detail": f"{field} must be {FIELD_LENGTH_LIMITS[field]} characters or fewer"},
status=400,
)
return None
def validate_content_shape(kind, content):
lower = (content or "").lower()
if kind == Artifact.DECK and "<section" not in lower:
return Response(
{"detail": "deck needs top-level <section>...</section> blocks"}, status=400
)
if kind == Artifact.HTML_FRAGMENT and any(tag in lower for tag in ("<html", "<!doctype", "<body")):
return Response(
{"detail": "html_fragment must be body-only HTML; do not include doctype/html/body"},
status=400,
)
if kind == Artifact.HTML_FULL and "<html" not in lower:
return Response(
{"detail": "html_full must be a complete HTML document containing <html>"}, status=400
)
if kind == Artifact.MARKDOWN and not (content or "").strip():
return Response({"detail": "markdown content cannot be blank"}, status=400)
return None
def validate_source_fields(data):
length_error = validate_field_lengths(data, ["source", "title", "source_name", "source_id", "theme"])
if length_error:
return length_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)