"""Tests for input size limits on issue and comment write endpoints. Verifies that Pydantic validation enforces the goldilocks limits designed for agent-swarm use while preventing abuse: body: max 50,000 chars (bumped from 10k for agent-written issues) title: max 500 chars labels: max 20 items, each max 100 chars symbol_anchors: max 50 items, each max 500 chars commit_anchors: max 50 items, each max 71 chars (sha256:<64-hex> canonical form) comment body: max 50,000 chars All limits are enforced at the Pydantic layer (422 before the DB is touched). """ from __future__ import annotations import pytest from httpx import AsyncClient from muse.core.types import long_id from musehub.types.json_types import StrDict # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- async def _create_repo(client: AsyncClient, headers: StrDict, name: str) -> str: r = await client.post("/api/repos", json={"name": name, "owner": "testuser"}, headers=headers) assert r.status_code == 201 return r.json()["repoId"] async def _create_issue(client: AsyncClient, headers: StrDict, repo_id: str, **kwargs: str | int | bool | None) -> int: payload = {"title": "baseline", "body": "", **kwargs} r = await client.post(f"/api/repos/{repo_id}/issues", json=payload, headers=headers) assert r.status_code == 201 return r.json()["number"] # --------------------------------------------------------------------------- # IssueCreate — body # --------------------------------------------------------------------------- async def test_issue_body_at_limit_accepted(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-body-ok") r = await client.post( f"/api/repos/{repo_id}/issues", json={"title": "t", "body": "x" * 50_000}, headers=auth_headers, ) assert r.status_code == 201 async def test_issue_body_over_limit_rejected(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-body-over") r = await client.post( f"/api/repos/{repo_id}/issues", json={"title": "t", "body": "x" * 50_001}, headers=auth_headers, ) assert r.status_code == 422 # --------------------------------------------------------------------------- # IssueCreate — title # --------------------------------------------------------------------------- async def test_issue_title_at_limit_accepted(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-title-ok") r = await client.post( f"/api/repos/{repo_id}/issues", json={"title": "t" * 500}, headers=auth_headers, ) assert r.status_code == 201 async def test_issue_title_over_limit_rejected(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-title-over") r = await client.post( f"/api/repos/{repo_id}/issues", json={"title": "t" * 501}, headers=auth_headers, ) assert r.status_code == 422 # --------------------------------------------------------------------------- # IssueCreate — labels # --------------------------------------------------------------------------- async def test_issue_labels_at_limit_accepted(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-labels-ok") r = await client.post( f"/api/repos/{repo_id}/issues", json={"title": "t", "labels": [f"label-{i}" for i in range(20)]}, headers=auth_headers, ) assert r.status_code == 201 async def test_issue_labels_too_many_rejected(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-labels-over") r = await client.post( f"/api/repos/{repo_id}/issues", json={"title": "t", "labels": [f"label-{i}" for i in range(21)]}, headers=auth_headers, ) assert r.status_code == 422 async def test_issue_label_item_too_long_rejected(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-label-item") r = await client.post( f"/api/repos/{repo_id}/issues", json={"title": "t", "labels": ["x" * 101]}, headers=auth_headers, ) assert r.status_code == 422 async def test_issue_label_item_at_limit_accepted(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-label-item-ok") r = await client.post( f"/api/repos/{repo_id}/issues", json={"title": "t", "labels": ["x" * 100]}, headers=auth_headers, ) assert r.status_code == 201 # --------------------------------------------------------------------------- # IssueCreate — symbol_anchors # --------------------------------------------------------------------------- async def test_issue_symbol_anchors_at_limit_accepted(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-sym-ok") anchors = [f"path/to/file.py::Symbol{i}" for i in range(50)] r = await client.post( f"/api/repos/{repo_id}/issues", json={"title": "t", "symbolAnchors": anchors}, headers=auth_headers, ) assert r.status_code == 201 async def test_issue_symbol_anchors_too_many_rejected(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-sym-over") anchors = [f"path/to/file.py::Symbol{i}" for i in range(51)] r = await client.post( f"/api/repos/{repo_id}/issues", json={"title": "t", "symbolAnchors": anchors}, headers=auth_headers, ) assert r.status_code == 422 async def test_issue_symbol_anchor_item_too_long_rejected(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-sym-item") r = await client.post( f"/api/repos/{repo_id}/issues", json={"title": "t", "symbolAnchors": ["x" * 501]}, headers=auth_headers, ) assert r.status_code == 422 # --------------------------------------------------------------------------- # IssueCreate — commit_anchors # --------------------------------------------------------------------------- async def test_issue_commit_anchor_canonical_prefix_accepted(client: AsyncClient, auth_headers: StrDict) -> None: """sha256:<64-hex> is the canonical commit ID format (71 chars) and must be accepted.""" repo_id = await _create_repo(client, auth_headers, "il-commit-canonical") import secrets hex64 = secrets.token_hex(32) canonical = long_id(hex64) assert len(canonical) == 71 r = await client.post( f"/api/repos/{repo_id}/issues", json={"title": "t", "commitAnchors": [canonical]}, headers=auth_headers, ) assert r.status_code == 201, ( f"Canonical sha256:<64-hex> commit anchor (71 chars) was rejected with {r.status_code}: {r.text}. " "The max_length for commit anchors must be 71 to fit the 'sha256:' prefix." ) async def test_issue_commit_anchors_at_limit_accepted(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-commit-ok") import secrets anchors = [secrets.token_hex(32) for _ in range(50)] r = await client.post( f"/api/repos/{repo_id}/issues", json={"title": "t", "commitAnchors": anchors}, headers=auth_headers, ) assert r.status_code == 201 async def test_issue_commit_anchors_too_many_rejected(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-commit-over") import secrets anchors = [secrets.token_hex(32) for _ in range(51)] r = await client.post( f"/api/repos/{repo_id}/issues", json={"title": "t", "commitAnchors": anchors}, headers=auth_headers, ) assert r.status_code == 422 async def test_issue_commit_anchor_item_too_long_rejected(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-commit-item") r = await client.post( f"/api/repos/{repo_id}/issues", json={"title": "t", "commitAnchors": ["a" * 72]}, headers=auth_headers, ) assert r.status_code == 422 # --------------------------------------------------------------------------- # IssueCommentCreate — body # --------------------------------------------------------------------------- async def test_comment_body_at_limit_accepted(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-cmt-ok") number = await _create_issue(client, auth_headers, repo_id) r = await client.post( f"/api/repos/{repo_id}/issues/{number}/comments", json={"body": "x" * 50_000}, headers=auth_headers, ) assert r.status_code == 201 async def test_create_comment_returns_single_resource(client: AsyncClient, auth_headers: StrDict) -> None: """POST .../comments must return the created comment as a flat single resource, not a list.""" repo_id = await _create_repo(client, auth_headers, "il-cmt-shape") number = await _create_issue(client, auth_headers, repo_id) r = await client.post( f"/api/repos/{repo_id}/issues/{number}/comments", json={"body": "hello world"}, headers=auth_headers, ) assert r.status_code == 201 data = r.json() # Must be a flat resource — not a list envelope. assert "commentId" in data, f"expected 'commentId' key, got: {list(data.keys())}" assert "comments" not in data, "response must not wrap in list envelope" assert data["body"] == "hello world" assert "author" in data assert "createdAt" in data async def test_comment_body_over_limit_rejected(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-cmt-over") number = await _create_issue(client, auth_headers, repo_id) r = await client.post( f"/api/repos/{repo_id}/issues/{number}/comments", json={"body": "x" * 50_001}, headers=auth_headers, ) assert r.status_code == 422 async def test_comment_body_empty_rejected(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-cmt-empty") number = await _create_issue(client, auth_headers, repo_id) r = await client.post( f"/api/repos/{repo_id}/issues/{number}/comments", json={"body": ""}, headers=auth_headers, ) assert r.status_code == 422 # --------------------------------------------------------------------------- # IssueLabelAssignRequest # --------------------------------------------------------------------------- async def test_label_assign_too_many_rejected(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-lbl-assign") number = await _create_issue(client, auth_headers, repo_id) r = await client.post( f"/api/repos/{repo_id}/issues/{number}/labels", json={"labels": [f"lbl-{i}" for i in range(21)]}, headers=auth_headers, ) assert r.status_code == 422 async def test_label_assign_item_too_long_rejected(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-lbl-item") number = await _create_issue(client, auth_headers, repo_id) r = await client.post( f"/api/repos/{repo_id}/issues/{number}/labels", json={"labels": ["x" * 101]}, headers=auth_headers, ) assert r.status_code == 422 # --------------------------------------------------------------------------- # IssueUpdate — same limits apply on PATCH # --------------------------------------------------------------------------- async def test_update_body_over_limit_rejected(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-upd-body") number = await _create_issue(client, auth_headers, repo_id) r = await client.patch( f"/api/repos/{repo_id}/issues/{number}", json={"body": "x" * 50_001}, headers=auth_headers, ) assert r.status_code == 422 async def test_update_labels_too_many_rejected(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-upd-labels") number = await _create_issue(client, auth_headers, repo_id) r = await client.patch( f"/api/repos/{repo_id}/issues/{number}", json={"labels": [f"l{i}" for i in range(21)]}, headers=auth_headers, ) assert r.status_code == 422 async def test_update_symbol_anchors_too_many_rejected(client: AsyncClient, auth_headers: StrDict) -> None: repo_id = await _create_repo(client, auth_headers, "il-upd-sym") number = await _create_issue(client, auth_headers, repo_id) r = await client.patch( f"/api/repos/{repo_id}/issues/{number}", json={"symbolAnchors": [f"f.py::S{i}" for i in range(51)]}, headers=auth_headers, ) assert r.status_code == 422