Files
turnstone/tests/test_skill_parse_api.py
T
Patrick Buckley 0a8083e6d5 feat(skills): paste SKILL.md to auto-fill the Create Skill modal (#477)
* feat(skills): paste SKILL.md to auto-fill the Create Skill modal

When a user pastes an Anthropic-style SKILL.md (YAML frontmatter +
markdown body) into the Create Skill content textarea, the frontend
sniffs the leading ``---``, posts the raw text to a new backend parse
endpoint, and populates name / description / tags / author / version /
license / compatibility / allowed_tools from the parsed fields.  The
textarea is left with the body only (frontmatter stripped), and a toast
reports how many fields were set vs. kept (already-typed values are
preserved).

Backend
- ``POST /v1/api/admin/skills/parse`` (admin.skills permission) wraps
  the existing ``turnstone.core.skill_parser.parse_skill_md`` so admin
  imports and external installs share one parser.  ``ParseSkillRequest``
  / ``ParseSkillResponse`` schemas added; OpenAPI spec + sync/async
  console SDK methods updated.
- Hardening: 32 KiB cap on ``raw`` (Pydantic ``max_length`` + handler
  enforcement); ``Content-Length`` pre-check returns 413 before any body
  buffering; parse offloaded via ``asyncio.to_thread`` so deeply-nested
  YAML cannot stall the event loop.

Frontend (turnstone/console/static)
- New paste handler with optimistic paint (raw text shown immediately,
  textarea disabled + ``aria-busy`` flipped, hint switches to
  "Parsing...") so the round-trip is visible on slow networks.
- ``AbortController`` + generation guard (``_ctmPasteController``) so a
  fresh paste or modal close cancels a stale fetch — the previous
  handler's callbacks see the controller has been replaced and bail
  before touching the DOM.
- Non-destructive overwrite: ``_setSkillFormField`` returns "filled" /
  "skipped" / "absent" and refuses to clobber non-empty values.  Toast
  reports counts.
- Bumps ``#toast`` z-index above modal overlays (was 200 vs. modal 600
  — toasts fired while a modal was open were invisible).  Console-wide
  fix exposed by this being the first feature to fire toasts mid-modal.

HTML / CSS
- New ``.skill-paste-hint`` line above the textarea announcing the
  affordance, sized to match surrounding ``.label-hint`` text.
- ``aria-describedby`` ties the hint to the textarea; ``aria-live=
  "polite"`` announces the busy-state transition to screen readers.
- "Skill Content" heading hint reworded "system message — ..." →
  "available: ..." and the variables row label "Variables" → "Used"
  to disambiguate available vs. in-use template variables.

Tests
- 11 new cases in ``tests/test_skill_parse_api.py``: happy paths
  (full / minimal / nested-metadata / unquoted-colon recovery),
  malformed YAML 400, missing/blank/missing-name 400, RBAC 403, raw
  body 32 KiB cap (Content-Length pre-check), chunked-encoding bypass
  forces the application-layer cap.  Test pins ``raw_frontmatter``
  omission so a future ``dataclasses.asdict`` refactor can't silently
  leak the full YAML dict back to clients.

Validation
- 5146 / 5146 ``pytest -k "not live"`` pass.
- ``ruff`` + ``mypy`` clean on changed sources.
- ``node -c`` clean on governance.js.
- Two-stage code review (full pipeline + bug+quality re-review of the
  fix patches) applied; all confirmed findings addressed.

* fix(skills): Copilot PR #477 review fixes (cumulative bug-1, bug-2, q-1)

bug-1 (server.py): Content-Length pre-check was clamped to 32 KiB —
the same number as the per-string char cap on ``raw``.  A legitimate
``raw`` of exactly 32 KiB produces a JSON body well above 32 KiB once
the ``{"raw":"..."}`` wrapper and any escaping is added, so valid
near-max requests were 413'd.  New constant
``_PARSE_SKILL_MAX_BODY_BYTES = _PARSE_SKILL_MAX_CHARS * 4`` admits the
wrapper + multibyte expansion while still refusing obviously oversized
payloads early; the per-string ``len(raw)`` check stays authoritative.

bug-2 (governance.js): hideCreateTemplateModal aborted the inflight
paste controller and nulled the global, but the handler's ``.catch``
and ``.finally`` guard each DOM mutation behind ``_isCurrent()`` —
both bail when the controller has been nulled, leaving the textarea
``disabled`` + ``aria-busy`` and the hint stuck on "Parsing…".
Reopening the modal landed on a poisoned state.  The second-pass
review's q-2 cleanup that dropped the show-side defensive reset
missed this scenario — the verifier's reachability argument confused
"controller is null" with "UI state is reset"; the two are
independent.  Hide now resets the paste-induced visible state
alongside the abort.

q-1 (console_spec.py): error_codes for the parse endpoint listed only
400; handler also returns 413 for oversized bodies.  Added 413; kept
403 implicit per the convention sibling admin endpoints follow.

Test fixup: bumped the Content-Length test payload to 200 KB so it
clearly exceeds the new 128 KB pre-check threshold; otherwise it was
falling through to the per-string check and duplicating
test_oversized_raw_chunked_returns_413's coverage.
2026-05-04 16:12:53 -07:00

233 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the SKILL.md parse admin API endpoint.
The endpoint is a thin permission-checked wrapper around
``turnstone.core.skill_parser.parse_skill_md``. These tests cover the
routing, auth, and error-handling layers — parser semantics live in
``test_skill_parser.py``.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Mount, Route
from starlette.testclient import TestClient
if TYPE_CHECKING:
from collections.abc import Iterator
from starlette.requests import Request
from starlette.responses import Response
from turnstone.console.server import admin_parse_skill
from turnstone.core.auth import AuthResult
class _InjectAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> Response:
request.state.auth_result = AuthResult(
user_id="test-user",
scopes=frozenset({"approve"}),
token_source="config",
permissions=frozenset({"read", "write", "approve", "admin.skills"}),
)
return await call_next(request)
class _InjectAuthNoSkillsMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> Response:
request.state.auth_result = AuthResult(
user_id="test-user",
scopes=frozenset({"approve"}),
token_source="jwt",
permissions=frozenset({"read", "write", "approve"}),
)
return await call_next(request)
_ROUTES = [
Mount(
"/v1",
routes=[
Route("/api/admin/skills/parse", admin_parse_skill, methods=["POST"]),
],
),
]
@pytest.fixture
def client() -> TestClient:
app = Starlette(
routes=_ROUTES,
middleware=[Middleware(_InjectAuthMiddleware)],
)
return TestClient(app)
@pytest.fixture
def client_no_perm() -> TestClient:
app = Starlette(
routes=_ROUTES,
middleware=[Middleware(_InjectAuthNoSkillsMiddleware)],
)
return TestClient(app)
_FULL_SKILL = """\
---
name: code-review
description: Automated code review skill
author: Test Author
version: 2.0.0
tags: [python, review, quality]
allowed-tools: [read_file, list_directory]
license: MIT
compatibility: ">=0.7"
---
# Code Review
Review code for best practices.
"""
_MINIMAL_SKILL = """\
---
name: minimal
---
Just some content.
"""
class TestParseSkill:
def test_parses_full_frontmatter(self, client: TestClient) -> None:
resp = client.post("/v1/api/admin/skills/parse", json={"raw": _FULL_SKILL})
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "code-review"
assert data["description"] == "Automated code review skill"
assert data["author"] == "Test Author"
assert data["version"] == "2.0.0"
assert data["tags"] == ["python", "review", "quality"]
assert data["allowed_tools"] == ["read_file", "list_directory"]
assert data["license"] == "MIT"
assert data["compatibility"] == ">=0.7"
assert "# Code Review" in data["content"]
# Frontmatter should not leak into the body.
assert "name: code-review" not in data["content"]
# ParsedSkill carries raw_frontmatter (the full YAML dict) but the
# handler whitelists fields by hand to avoid leaking arbitrary keys.
# Pin that contract — a future refactor to dataclasses.asdict would
# silently break it without this assertion.
assert "raw_frontmatter" not in data
def test_parses_minimal_frontmatter(self, client: TestClient) -> None:
resp = client.post("/v1/api/admin/skills/parse", json={"raw": _MINIMAL_SKILL})
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "minimal"
assert data["description"] == "Just some content."
assert data["version"] == "1.0.0"
assert data["tags"] == []
assert data["allowed_tools"] == []
assert data["license"] == ""
def test_anthropic_nested_metadata_tags(self, client: TestClient) -> None:
# Anthropic-style skill puts tags under metadata.tags rather than
# at the top level — the parser must handle both layouts.
raw = """\
---
name: nested-meta
description: A skill using nested metadata
metadata:
tags: [alpha, beta]
author: Anthropic
version: 3.1.4
---
Body.
"""
resp = client.post("/v1/api/admin/skills/parse", json={"raw": raw})
assert resp.status_code == 200
data = resp.json()
assert data["tags"] == ["alpha", "beta"]
assert data["author"] == "Anthropic"
assert data["version"] == "3.1.4"
def test_unquoted_colon_in_description(self, client: TestClient) -> None:
# Common cross-client mistake: ``description: Use when: the user...``
# The parser retries with the description value quoted.
raw = """\
---
name: colon-desc
description: Use when: the user asks for a review
---
Body.
"""
resp = client.post("/v1/api/admin/skills/parse", json={"raw": raw})
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "colon-desc"
assert "Use when" in data["description"]
def test_missing_name_returns_400(self, client: TestClient) -> None:
raw = """\
---
description: No name field
---
Body.
"""
resp = client.post("/v1/api/admin/skills/parse", json={"raw": raw})
assert resp.status_code == 400
assert "name" in resp.json()["error"].lower()
def test_missing_raw_returns_400(self, client: TestClient) -> None:
resp = client.post("/v1/api/admin/skills/parse", json={})
assert resp.status_code == 400
assert "raw" in resp.json()["error"].lower()
def test_blank_raw_returns_400(self, client: TestClient) -> None:
resp = client.post("/v1/api/admin/skills/parse", json={"raw": " \n"})
assert resp.status_code == 400
def test_invalid_yaml_returns_400(self, client: TestClient) -> None:
# YAML that the malformed-description retry can't fix.
raw = "---\nname: [not, valid, here\n---\nBody.\n"
resp = client.post("/v1/api/admin/skills/parse", json={"raw": raw})
assert resp.status_code == 400
def test_requires_admin_skills_permission(self, client_no_perm: TestClient) -> None:
resp = client_no_perm.post("/v1/api/admin/skills/parse", json={"raw": _MINIMAL_SKILL})
assert resp.status_code == 403
def test_oversized_content_length_returns_413(self, client: TestClient) -> None:
# Content-Length pre-check rejects oversized bodies before they're
# buffered into memory. Caps worker memory against an admin-token
# holder spraying multi-GB JSON. The threshold is generous (~4×
# the per-string cap) so payload here must clearly exceed it.
oversized = "a" * 200_000
resp = client.post("/v1/api/admin/skills/parse", json={"raw": oversized})
assert resp.status_code == 413
def test_oversized_raw_chunked_returns_413(self, client: TestClient) -> None:
# When the client sends Transfer-Encoding: chunked there is no
# Content-Length header, so the pre-check is skipped and the
# application-layer cap is the only line of defence. httpx switches
# to chunked when the body is a generator.
def _gen() -> Iterator[bytes]:
yield b'{"raw":"' + b"a" * 33_000 + b'"}'
resp = client.post(
"/v1/api/admin/skills/parse",
content=_gen(),
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 413
assert "raw" in resp.json()["error"].lower()