"""End-to-end smoke tests for v32 endpoints + Flea allowlist behavior. * GET /marketplace/format-guide — auth-gated (any logged-in user, no admin), renders the markdown source. * /api/marketplace/curated/.../asset/{path} — path-traversal guard, RBAC. * Flea upload allowlist on /api/store/entities (photo + doc). """ from __future__ import annotations import io from pathlib import Path import pytest # --- /marketplace/format-guide ------------------------------------------- def test_format_guide_requires_login(seeded_app): """Anonymous user gets redirected (302) to /login — no public access.""" client = seeded_app["client"] r = client.get("/marketplace/format-guide", follow_redirects=False) # The guide endpoint is wrapped by the same auth dependency the rest of # /marketplace/* uses; the exact response is a 302/303 to /login or a # 401 depending on the auth provider. Either way it's not a 200. assert r.status_code in (302, 303, 307, 401) def test_format_guide_renders_for_logged_in_user(seeded_app): """Any logged-in user (admin in this seeded fixture) sees the rendered page. The rendered HTML must: * carry the curator-focused title (post-walkthrough rewrite), * include the disclaimer scoping the guide to the Curated Marketplace channel only (so a curator doesn't think it applies to the Flea upload wizard), * render the fenced JSON example as a
 block.
    """
    client = seeded_app["client"]
    headers = {"Authorization": f"Bearer {seeded_app['admin_token']}"}
    r = client.get("/marketplace/format-guide", headers=headers)
    assert r.status_code == 200
    body = r.text
    # Title points at curators (rewrite from the original "format guide").
    assert "Curated Marketplace" in body
    # Channel-scoping disclaimer near the top of the page.
    assert "Curated Marketplace channel only" in body
    # JSON examples render as code blocks.
    assert "
" in body or "", "image/svg+xml")),
        ],
        data={"type": "skill", "version": "1.0", "description": "Use when validating flea-market endpoint integrations across guardrails"},
    )
    assert r.status_code == 415
    assert "photo_unsupported_format" in r.text


# --- /api/marketplace/curated/.../asset path-traversal guard -------------


_PNG_1x1 = (
    b"\x89PNG\r\n\x1a\n"
    b"\x00\x00\x00\rIHDR"
    b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00"
    b"\x1f\x15\xc4\x89"
    b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01"
    b"\r\n-\xb4"
    b"\x00\x00\x00\x00IEND\xaeB`\x82"
)


def test_curated_asset_endpoint_blocks_path_traversal(seeded_app, monkeypatch, tmp_path):
    """Hitting the asset endpoint with `../` segments must return 404 (not the
    file outside the marketplace dir). The route's ``Path.resolve(strict=True)
    + is_relative_to`` guard is the load-bearing check."""
    from src.db import get_system_db
    from src.repositories.marketplace_registry import MarketplaceRegistryRepository
    from src.repositories.resource_grants import ResourceGrantsRepository

    # Stand up a tiny on-disk marketplace at the configured marketplaces dir.
    # The asset endpoint is image-only post-#234-review; the seeded fixture
    # carries a real PNG (with magic bytes) so the sanity-200 case still
    # works alongside the path-traversal regression check.
    data_dir = Path(seeded_app["env"]["data_dir"])
    repo_root = data_dir / "marketplaces" / "test-mp"
    repo_root.mkdir(parents=True, exist_ok=True)
    (repo_root / "ok.png").write_bytes(_PNG_1x1)

    # Register the marketplace (with curator) + grant Admin access to its
    # synthetic plugin so the resource_grants check passes.
    conn = get_system_db()
    try:
        MarketplaceRegistryRepository(conn).register(
            id="test-mp", name="Test", url="https://example.com/x.git",
            curator_name="C", curator_email="c@example.com",
        )
        # Insert a plugin row so the RBAC check has a target. Resource id is
        # `/`.
        conn.execute(
            "INSERT OR REPLACE INTO marketplace_plugins "
            "(marketplace_id, name) VALUES ('test-mp', 'demo')"
        )
        admin_group = conn.execute(
            "SELECT id FROM user_groups WHERE name = 'Admin'"
        ).fetchone()
        if admin_group:
            try:
                ResourceGrantsRepository(conn).create(
                    group_id=admin_group[0],
                    resource_type="marketplace_plugin",
                    resource_id="test-mp/demo",
                    assigned_by="test",
                )
            except Exception:
                pass  # already granted from a prior test run
    finally:
        conn.close()

    client = seeded_app["client"]
    headers = {"Authorization": f"Bearer {seeded_app['admin_token']}"}

    # Sanity: a normal in-tree image fetch returns 200.
    r = client.get(
        "/api/marketplace/curated/test-mp/demo/asset/ok.png",
        headers=headers,
    )
    assert r.status_code == 200
    assert r.content == _PNG_1x1

    # Traversal attempt: `..` segments. FastAPI's path param accepts the
    # value; our ``_safe_join`` resolves it then checks containment, so the
    # result is 404 (not 200, not 500).
    r = client.get(
        "/api/marketplace/curated/test-mp/demo/asset/../escape.txt",
        headers=headers,
    )
    assert r.status_code == 404


# --- /asset/{path} XSS hardening (#234 review #3) -------------------------


def _seed_asset_marketplace(seeded_app, *, slug="xss-mp", plugin="demo"):
    """Helper: register marketplace + grant Admin RBAC. Returns repo_root."""
    from src.db import get_system_db
    from src.repositories.marketplace_registry import MarketplaceRegistryRepository
    from src.repositories.resource_grants import ResourceGrantsRepository

    data_dir = Path(seeded_app["env"]["data_dir"])
    repo_root = data_dir / "marketplaces" / slug
    repo_root.mkdir(parents=True, exist_ok=True)

    conn = get_system_db()
    try:
        try:
            MarketplaceRegistryRepository(conn).register(
                id=slug, name=slug, url=f"https://example.com/{slug}.git",
                curator_name="C", curator_email="c@example.com",
            )
        except Exception:
            pass  # already registered
        conn.execute(
            "INSERT OR REPLACE INTO marketplace_plugins "
            "(marketplace_id, name) VALUES (?, ?)", [slug, plugin],
        )
        admin_group = conn.execute(
            "SELECT id FROM user_groups WHERE name = 'Admin'"
        ).fetchone()
        if admin_group:
            try:
                ResourceGrantsRepository(conn).create(
                    group_id=admin_group[0],
                    resource_type="marketplace_plugin",
                    resource_id=f"{slug}/{plugin}",
                    assigned_by="test",
                )
            except Exception:
                pass
    finally:
        conn.close()
    return repo_root


def test_curated_asset_rejects_html_extension(seeded_app):
    """A curator-planted ``.html`` in the cloned repo MUST NOT be served as
    ``text/html`` — extension allowlist denies any non-image extension."""
    repo_root = _seed_asset_marketplace(seeded_app, slug="xss-html")
    (repo_root / "evil.html").write_text(
        "", encoding="utf-8",
    )
    client = seeded_app["client"]
    headers = {"Authorization": f"Bearer {seeded_app['admin_token']}"}

    r = client.get(
        "/api/marketplace/curated/xss-html/demo/asset/evil.html",
        headers=headers,
    )
    assert r.status_code == 415
    assert "unsupported_asset_extension" in r.text


def test_curated_asset_renamed_html_is_neutered_by_headers(seeded_app):
    """A curator who renames ``evil.html`` to ``evil.png`` no longer triggers
    a 415 — magic-bytes validation was dropped from the request path (see
    CHANGELOG entry under [Unreleased] → Changed). The remaining defense
    layers neuter the payload at the browser:

    * ``Content-Type`` pinned to ``image/png`` from the extension table, so
      the browser never parses the body as HTML.
    * ``X-Content-Type-Options: nosniff`` so the browser refuses to second-
      guess the declared Content-Type.
    * Strict CSP (``default-src 'none'``) so even if HTML did render,
      scripts/iframes/etc. couldn't execute.

    Body validation happens at curator-content-acceptance time (git fetch
    against the admin-registered upstream repo), not on every GET request.
    """
    repo_root = _seed_asset_marketplace(seeded_app, slug="xss-rename")
    (repo_root / "evil.png").write_text(
        "", encoding="utf-8",
    )
    client = seeded_app["client"]
    headers = {"Authorization": f"Bearer {seeded_app['admin_token']}"}

    r = client.get(
        "/api/marketplace/curated/xss-rename/demo/asset/evil.png",
        headers=headers,
    )
    assert r.status_code == 200
    assert r.headers.get("content-type", "").startswith("image/png")
    assert r.headers.get("x-content-type-options") == "nosniff"
    csp = r.headers.get("content-security-policy", "")
    assert "default-src 'none'" in csp


def test_curated_asset_rejects_svg_extension(seeded_app):
    """SVG is intentionally OUT of the allowlist — ``',
        encoding="utf-8",
    )
    client = seeded_app["client"]
    headers = {"Authorization": f"Bearer {seeded_app['admin_token']}"}

    r = client.get(
        "/api/marketplace/curated/xss-svg/demo/asset/logo.svg",
        headers=headers,
    )
    assert r.status_code == 415


def test_curated_asset_serves_valid_png_with_security_headers(seeded_app):
    """Happy path: a real PNG returns 200, ``Content-Type: image/png``,
    plus the defense-in-depth headers ``X-Content-Type-Options: nosniff``
    and a strict ``Content-Security-Policy`` that blocks script execution
    even if a future regression let HTML through.
    """
    repo_root = _seed_asset_marketplace(seeded_app, slug="xss-ok")
    (repo_root / "cover.png").write_bytes(_PNG_1x1)
    client = seeded_app["client"]
    headers = {"Authorization": f"Bearer {seeded_app['admin_token']}"}

    r = client.get(
        "/api/marketplace/curated/xss-ok/demo/asset/cover.png",
        headers=headers,
    )
    assert r.status_code == 200
    assert r.headers.get("content-type", "").startswith("image/png")
    assert r.headers.get("x-content-type-options") == "nosniff"
    csp = r.headers.get("content-security-policy", "")
    assert "default-src 'none'" in csp
    assert "script-src" not in csp or "'none'" in csp  # no script source allowed


# --- Content-Disposition force-download on /doc and /mirrored/docs --------


def _seed_marketplace_with_doc(seeded_app, *, slug="dl-mp", plugin="demo"):
    """Helper: register a marketplace, drop a sample doc into its working
    tree, and grant Admin RBAC. Returns the data_dir for further fixturing."""
    from src.db import get_system_db
    from src.repositories.marketplace_registry import MarketplaceRegistryRepository
    from src.repositories.resource_grants import ResourceGrantsRepository

    data_dir = Path(seeded_app["env"]["data_dir"])
    repo_root = data_dir / "marketplaces" / slug
    repo_root.mkdir(parents=True, exist_ok=True)
    (repo_root / "docs").mkdir(parents=True, exist_ok=True)
    (repo_root / "docs" / "guide.md").write_text("# Guide\n", encoding="utf-8")
    (repo_root / "logo.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8)

    conn = get_system_db()
    try:
        MarketplaceRegistryRepository(conn).register(
            id=slug, name=slug, url=f"https://example.com/{slug}.git",
            curator_name="C", curator_email="c@example.com",
        )
        conn.execute(
            "INSERT OR REPLACE INTO marketplace_plugins "
            "(marketplace_id, name) VALUES (?, ?)", [slug, plugin],
        )
        admin_group = conn.execute(
            "SELECT id FROM user_groups WHERE name = 'Admin'"
        ).fetchone()
        if admin_group:
            try:
                ResourceGrantsRepository(conn).create(
                    group_id=admin_group[0],
                    resource_type="marketplace_plugin",
                    resource_id=f"{slug}/{plugin}",
                    assigned_by="test",
                )
            except Exception:
                pass
    finally:
        conn.close()
    return data_dir


def test_curated_doc_endpoint_sets_attachment_disposition(seeded_app):
    """/doc serves as `Content-Disposition: attachment` so clicks download
    the file rather than open it inline. /asset stays inline (covers belong
    in , not as downloads)."""
    _seed_marketplace_with_doc(seeded_app)
    client = seeded_app["client"]
    headers = {"Authorization": f"Bearer {seeded_app['admin_token']}"}

    # Doc → attachment + filename hint
    r = client.get(
        "/api/marketplace/curated/dl-mp/demo/doc/docs/guide.md",
        headers=headers,
    )
    assert r.status_code == 200
    assert "attachment" in r.headers.get("content-disposition", "").lower()
    assert "guide.md" in r.headers.get("content-disposition", "")

    # Asset (cover image) → no attachment header (renders inline)
    r = client.get(
        "/api/marketplace/curated/dl-mp/demo/asset/logo.png",
        headers=headers,
    )
    assert r.status_code == 200
    assert "attachment" not in r.headers.get("content-disposition", "").lower()


def test_curated_doc_endpoint_rejects_non_allowlist_extension(seeded_app):
    """The /doc endpoint refuses to serve a file whose extension isn't in
    the allowlist (PDF / Markdown / plain text). Defense-in-depth even when
    the parser lets a curator's exotic path slip through."""
    data_dir = _seed_marketplace_with_doc(seeded_app)
    # Plant a .docx file in the seeded marketplace
    (data_dir / "marketplaces" / "dl-mp" / "docs" / "evil.docx").write_text(
        "PKfake", encoding="utf-8",
    )
    client = seeded_app["client"]
    headers = {"Authorization": f"Bearer {seeded_app['admin_token']}"}
    r = client.get(
        "/api/marketplace/curated/dl-mp/demo/doc/docs/evil.docx",
        headers=headers,
    )
    assert r.status_code == 415
    assert "unsupported_doc_extension" in r.text