* Add /marketplace browse page + Model B opt-in stack composition
New /marketplace browse surface unifies the curated marketplaces
(admin-managed git mirrors) and the community Flea Market behind
three tabs — Curated / Flea / My Stack — with per-tab category
filter, search across both sources with scope checkboxes, and
numeric pagination, all driven by URL query state. Plugin detail
at /marketplace/curated/<slug>/<plugin> and /marketplace/flea/<id>;
nested skill / agent detail at /marketplace/curated/<slug>/<plugin>/
{skill,agent}/<name> and the flea-side single-page detail.
Model B opt-in: an RBAC grant on a curated plugin is now only
*eligibility*. The user must click "Add to my stack" for it to
enter their served Claude Code marketplace. Composition flips
from (rbac ∖ opt_outs) ∪ store_installs to
(rbac ∩ subscriptions) ∪ store_installs. The legacy
user_plugin_optouts table is renamed user_curated_subscriptions
(schema v27) — same table shape, inverted semantic, repository
methods become subscribe / unsubscribe / is_subscribed.
UX vocabulary: Install → Add to my stack, Installed → In your
stack, card "Installed" badge → "In stack" (amber pill), tab
"My Subscriptions" → "My Stack". Bridges the two-step model
(server-side bookmark vs. on-laptop install) the previous label
hid. Click triggers an inline post-add hint panel under the
description with the agnes refresh-marketplace recipe + Copy
chip, dismissible per-browser via localStorage.
Per-tab info blocks above the filter row:
- Curated: trust signal — "Each plugin here has a named curator
accountable for it." (blue accent + See-all-curators link)
- Flea: open-shelf signal — "Anyone in the company can upload
here." (purple accent + Tips-for-sharing link)
- My Stack: personal-shelf orientation — "Your AI stack —
everything you've added." (slate accent, no link)
Tabs carry per-tab Heroicons (shield-check / building-storefront
/ rectangle-stack) tinted to match each tab's accent; flips white
when the tab is active for contrast.
Hero illustration anchored to the right of the blue hero panel
(absolute, 47% wide, behind the search row content). Hidden
under 900px viewport.
Action-row CTAs realigned to publication intent: curated
"How to add new content" → "Submit a plugin" (links to the
guide page); flea button removed since +Upload sits next to it.
Empty-state CTAs match. /marketplace/guide/{curated,flea}
routes now host publication-flow guide pages with placeholder
ledes — full copy to be authored separately.
Categories: Heroicons-based icons mapped per category in
src/category_icons.py (zero new dependencies; SVG path strings
inlined). Marketplace cards, filter pills, and detail pages
read from the same source.
API endpoints under /api/marketplace:
- GET /items per-tab listing (curated / flea / my)
- GET /categories per-tab non-zero counts
- GET /curated/{slug}/{plugin} plugin detail
- POST/DELETE /curated/{slug}/{plugin}/install subscribe toggle
- GET /curated/{slug}/{plugin}/{skill,agent}/{name} inner item
The tab=my branch reads directly from
user_curated_subscriptions ∪ user_store_installs (not
resolve_user_marketplace, which bundles flea skills/agents into
a single store-bundle synthetic entry useful for serving the
Claude Code marketplace ZIP/git but wrong for browsing where
each item should appear as its own card).
Detail pages: plugin detail surfaces inner skills/agents as
clickable nested cards; commands/hooks/MCPs render as plain
name lists. Skill/agent detail mirrors the plugin layout with
kind-tinted accents (skill = green, agent = purple), Description
+ Details sidebar, Files + Docs sections, and the "How to call
it" copy-able invocation chip showing /<plugin>:<inner-name>
exactly as Claude Code namespaces it post-install. Curated
nested has no install button — links back to the parent plugin.
Navbar: standalone "My AI Stack" relabelled "My Stack" and
points at /marketplace?tab=my; "Store" link removed (Store
flow is reachable via the Flea Market tab's +Upload button).
The standalone /my-ai-stack and /store routes still work for
old bookmarks.
Tests cover the new browse / categories / install / RBAC paths
under tests/test_marketplace_api.py; existing marketplace and
store tests updated for Model B (explicit subscribe in fixtures).
Schema bumped v26 → v27 with idempotent migration that wipes
existing user_plugin_optouts rows on flip and adds
marketplace_plugins.created_at with registered_at backfill.
* Fix v28 migration + post-rebase test fallout
v28 ALTER TABLE marketplace_plugins ADD COLUMN created_at conflicted with
_SYSTEM_SCHEMA's earlier CREATE that already includes the column on fresh
installs (test fixtures starting at any pre-v28 version trip on it).
Switch to ADD COLUMN IF NOT EXISTS — same idiom as the upstream v27
Keboola sync-strategy migration on the same ladder.
Two test patches needed after the rebase bumped SCHEMA_VERSION 27 → 28:
- test_keboola_v27_migration.py: test_schema_version_constant_is_27 was
pinning ==27. Loosened to >=27 (the test's purpose is to verify the
v27 Keboola migration, not to pin the current SCHEMA_VERSION).
- test_setup_page_unified.py: was monkeypatching resolve_allowed_plugins
but compute_default_agent_prompt now reads from resolve_user_marketplace
(Model B-aware). Stub the right function so the test exercises the
v28 served-set path.
* Harden curated skill/agent inner endpoints against path traversal
`_read_inner`, the `skill_dir` walk in `curated_skill_detail`, and the
`agent_path.stat` in `curated_agent_detail` joined URL path-params onto
`plugin_root` without verifying the resolved candidate stayed inside it.
Starlette's `[^/]+` on `{skill_name}` / `{agent_name}` blocks the direct
URL exploit (encoded `/` 404s before the handler), but a curator-planted
symlink inside a curated marketplace's git mirror could still dereference
outside the plugin tree on read.
Adds `_safe_join(plugin_root, *parts)` doing
`Path.resolve(strict=True)` + `relative_to(plugin_root.resolve())`, used
by all three call sites so the boundary is enforced once and consistently.
Tests cover the helper directly (normal path resolves, escaping `..`
returns None, escaping symlink returns None, missing file returns None)
plus an end-to-end check that the symlink case actually 404s on the
HTTP endpoint. Symlink tests skip on Windows where symlink creation
needs elevated permissions; they run on Linux CI.
---------
Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
481 lines
19 KiB
Python
481 lines
19 KiB
Python
"""Integration tests for the unified /api/marketplace endpoints.
|
|
|
|
Covers the v28 Model B browse + install surface: per-tab listing,
|
|
categories, curated detail with RBAC guard, and subscribe/unsubscribe.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import zipfile
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
@pytest.fixture
|
|
def web_client(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
monkeypatch.setenv("TESTING", "1")
|
|
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-min-32-characters!!")
|
|
(tmp_path / "state").mkdir()
|
|
(tmp_path / "analytics").mkdir()
|
|
(tmp_path / "extracts").mkdir()
|
|
from src.db import close_system_db
|
|
close_system_db()
|
|
from app.main import create_app
|
|
app = create_app()
|
|
yield TestClient(app)
|
|
close_system_db()
|
|
|
|
|
|
def _create_user(client, email, password="UserPass1!"):
|
|
from argon2 import PasswordHasher
|
|
from src.db import get_system_db
|
|
from src.repositories.users import UserRepository
|
|
ph = PasswordHasher()
|
|
conn = get_system_db()
|
|
user_id = email.split("@")[0]
|
|
UserRepository(conn).create(
|
|
id=user_id, email=email, name=user_id, password_hash=ph.hash(password),
|
|
)
|
|
conn.close()
|
|
r = client.post("/auth/token", json={"email": email, "password": password})
|
|
assert r.status_code == 200, r.text
|
|
return user_id, {"access_token": r.json()["access_token"]}
|
|
|
|
|
|
def _seed_curated_grant(
|
|
*,
|
|
user_id: str,
|
|
marketplace: str,
|
|
plugin: str,
|
|
plugin_meta: dict | None = None,
|
|
group_name: str | None = None,
|
|
) -> tuple[str, str]:
|
|
from src.db import get_system_db
|
|
from src.repositories.user_groups import UserGroupsRepository
|
|
from src.repositories.user_group_members import UserGroupMembersRepository
|
|
from src.repositories.resource_grants import ResourceGrantsRepository
|
|
conn = get_system_db()
|
|
try:
|
|
existing = conn.execute(
|
|
"SELECT 1 FROM marketplace_registry WHERE id = ?", [marketplace],
|
|
).fetchone()
|
|
if not existing:
|
|
conn.execute(
|
|
"INSERT INTO marketplace_registry (id, name, url, registered_at) "
|
|
"VALUES (?, ?, ?, ?)",
|
|
[marketplace, marketplace.upper(),
|
|
f"https://example.test/{marketplace}.git",
|
|
datetime.now(timezone.utc)],
|
|
)
|
|
meta = {"name": plugin, "version": "1.0", "description": "desc"}
|
|
if plugin_meta:
|
|
meta.update(plugin_meta)
|
|
conn.execute(
|
|
"INSERT INTO marketplace_plugins "
|
|
"(marketplace_id, name, description, version, category, raw, updated_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
[
|
|
marketplace, plugin, meta.get("description"), meta.get("version"),
|
|
meta.get("category"), json.dumps(meta),
|
|
datetime.now(timezone.utc),
|
|
],
|
|
)
|
|
gname = group_name or f"G-{user_id}-{marketplace}"
|
|
gid = UserGroupsRepository(conn).create(name=gname)["id"]
|
|
UserGroupMembersRepository(conn).add_member(user_id, gid, source="admin")
|
|
grant_id = ResourceGrantsRepository(conn).create(
|
|
group_id=gid, resource_type="marketplace_plugin",
|
|
resource_id=f"{marketplace}/{plugin}",
|
|
)
|
|
return gid, grant_id
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _make_skill_zip(skill_name: str = "code-review") -> bytes:
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w") as zf:
|
|
zf.writestr(
|
|
f"{skill_name}/SKILL.md",
|
|
f"---\nname: {skill_name}\ndescription: A test skill.\n---\nbody",
|
|
)
|
|
return buf.getvalue()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /api/marketplace/items
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestListItems:
|
|
def test_curated_empty_for_user_without_grants(self, web_client):
|
|
_, cookies = _create_user(web_client, "alice@x.com")
|
|
r = web_client.get("/api/marketplace/items?tab=curated", cookies=cookies)
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["total"] == 0
|
|
assert data["items"] == []
|
|
|
|
def test_curated_lists_granted_plugins(self, web_client):
|
|
user_id, cookies = _create_user(web_client, "alice@x.com")
|
|
_seed_curated_grant(user_id=user_id, marketplace="mkt-x", plugin="alpha")
|
|
r = web_client.get("/api/marketplace/items?tab=curated", cookies=cookies)
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["total"] == 1
|
|
assert data["items"][0]["source"] == "curated"
|
|
assert data["items"][0]["name"] == "alpha"
|
|
assert data["items"][0]["installed"] is False
|
|
assert data["items"][0]["marketplace_slug"] == "mkt-x"
|
|
|
|
def test_flea_lists_uploads(self, web_client):
|
|
_, cookies = _create_user(web_client, "alice@x.com")
|
|
web_client.post(
|
|
"/api/store/entities",
|
|
files={"file": ("s.zip", _make_skill_zip("alpha"), "application/zip")},
|
|
data={"type": "skill"}, cookies=cookies,
|
|
)
|
|
r = web_client.get("/api/marketplace/items?tab=flea", cookies=cookies)
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["total"] == 1
|
|
assert data["items"][0]["source"] == "flea"
|
|
# Invocation name suffixed with -by-<owner>
|
|
assert "alpha" in data["items"][0]["name"]
|
|
|
|
def test_my_subscriptions_default_empty(self, web_client):
|
|
"""Without explicit install, a granted curated plugin doesn't show
|
|
up under tab=my (Model B)."""
|
|
user_id, cookies = _create_user(web_client, "alice@x.com")
|
|
_seed_curated_grant(user_id=user_id, marketplace="mkt-x", plugin="alpha")
|
|
r = web_client.get("/api/marketplace/items?tab=my", cookies=cookies)
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["total"] == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /api/marketplace/categories
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCategories:
|
|
def test_curated_categories_count(self, web_client):
|
|
user_id, cookies = _create_user(web_client, "alice@x.com")
|
|
_seed_curated_grant(
|
|
user_id=user_id, marketplace="mkt-x", plugin="alpha",
|
|
plugin_meta={"category": "Code & Engineering"},
|
|
)
|
|
_seed_curated_grant(
|
|
user_id=user_id, marketplace="mkt-x", plugin="beta",
|
|
plugin_meta={"category": "Code & Engineering"},
|
|
group_name="G-alice-mkt-x-beta",
|
|
)
|
|
r = web_client.get(
|
|
"/api/marketplace/categories?tab=curated", cookies=cookies,
|
|
)
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
cats = {c["name"]: c["count"] for c in data["items"]}
|
|
assert cats.get("Code & Engineering") == 2
|
|
|
|
def test_categories_skip_empty(self, web_client):
|
|
_, cookies = _create_user(web_client, "alice@x.com")
|
|
r = web_client.get(
|
|
"/api/marketplace/categories?tab=curated", cookies=cookies,
|
|
)
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["items"] == [] # no plugins in scope → no categories
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Curated detail + install
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCuratedDetail:
|
|
def test_detail_403_without_grant(self, web_client):
|
|
_, cookies = _create_user(web_client, "alice@x.com")
|
|
r = web_client.get(
|
|
"/api/marketplace/curated/some-mp/some-plugin", cookies=cookies,
|
|
)
|
|
assert r.status_code == 403
|
|
|
|
def test_detail_200_with_grant(self, web_client):
|
|
user_id, cookies = _create_user(web_client, "alice@x.com")
|
|
_seed_curated_grant(user_id=user_id, marketplace="mkt-x", plugin="alpha")
|
|
r = web_client.get(
|
|
"/api/marketplace/curated/mkt-x/alpha", cookies=cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
data = r.json()
|
|
assert data["plugin_name"] == "alpha"
|
|
assert data["installed"] is False
|
|
# New fields populated for the redesigned plugin detail page.
|
|
assert "files" in data and isinstance(data["files"], list)
|
|
assert "docs" in data and isinstance(data["docs"], list)
|
|
assert data["install_count"] == 0
|
|
|
|
def test_install_403_without_grant(self, web_client):
|
|
_, cookies = _create_user(web_client, "alice@x.com")
|
|
r = web_client.post(
|
|
"/api/marketplace/curated/some-mp/some-plugin/install",
|
|
cookies=cookies,
|
|
)
|
|
assert r.status_code == 403
|
|
|
|
def test_install_uninstall_round_trip(self, web_client):
|
|
user_id, cookies = _create_user(web_client, "alice@x.com")
|
|
_seed_curated_grant(user_id=user_id, marketplace="mkt-x", plugin="alpha")
|
|
|
|
# Install.
|
|
r = web_client.post(
|
|
"/api/marketplace/curated/mkt-x/alpha/install", cookies=cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
# Verify in DB.
|
|
from src.db import get_system_db
|
|
from src.repositories.user_curated_subscriptions import (
|
|
UserCuratedSubscriptionsRepository,
|
|
)
|
|
conn = get_system_db()
|
|
try:
|
|
assert UserCuratedSubscriptionsRepository(conn).is_subscribed(
|
|
user_id, "mkt-x", "alpha",
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
# Detail now reports installed=True.
|
|
d = web_client.get(
|
|
"/api/marketplace/curated/mkt-x/alpha", cookies=cookies,
|
|
).json()
|
|
assert d["installed"] is True
|
|
|
|
# Uninstall.
|
|
r = web_client.delete(
|
|
"/api/marketplace/curated/mkt-x/alpha/install", cookies=cookies,
|
|
)
|
|
assert r.status_code == 200
|
|
conn = get_system_db()
|
|
try:
|
|
assert not UserCuratedSubscriptionsRepository(conn).is_subscribed(
|
|
user_id, "mkt-x", "alpha",
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Curated nested skill / agent detail — extended response shape
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _seed_curated_skill_on_disk(
|
|
tmp_path, marketplace: str, plugin: str, skill: str,
|
|
*, files: dict[str, str] | None = None,
|
|
):
|
|
"""Materialize a skill on disk so curated_skill_detail can read it.
|
|
|
|
`files` maps relative paths inside the skill dir to file contents.
|
|
SKILL.md is always written; extra files surface in the Files section.
|
|
"""
|
|
skill_dir = tmp_path / "marketplaces" / marketplace / "plugins" / plugin / "skills" / skill
|
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
f"---\nname: {skill}\ndescription: A test skill.\n---\nbody",
|
|
encoding="utf-8",
|
|
)
|
|
for rel, content in (files or {}).items():
|
|
target = skill_dir / rel
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(content, encoding="utf-8")
|
|
|
|
|
|
def _seed_curated_agent_on_disk(
|
|
tmp_path, marketplace: str, plugin: str, agent: str,
|
|
):
|
|
agents_dir = tmp_path / "marketplaces" / marketplace / "plugins" / plugin / "agents"
|
|
agents_dir.mkdir(parents=True, exist_ok=True)
|
|
(agents_dir / f"{agent}.md").write_text(
|
|
f"---\nname: {agent}\ndescription: A test agent.\n---\nbody",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
class TestCuratedInnerDetail:
|
|
def test_skill_detail_includes_parent_metadata_and_files(
|
|
self, web_client, tmp_path,
|
|
):
|
|
user_id, cookies = _create_user(web_client, "alice@x.com")
|
|
_seed_curated_grant(
|
|
user_id=user_id, marketplace="mkt-x", plugin="alpha",
|
|
plugin_meta={"category": "Data", "author": {"name": "ops-team"}},
|
|
)
|
|
_seed_curated_skill_on_disk(
|
|
tmp_path, "mkt-x", "alpha", "data-explorer",
|
|
files={"REFERENCE.md": "ref docs"},
|
|
)
|
|
r = web_client.get(
|
|
"/api/marketplace/curated/mkt-x/alpha/skill/data-explorer",
|
|
cookies=cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
d = r.json()
|
|
# Inner-detail fields.
|
|
assert d["kind"] == "skill"
|
|
assert d["name"] == "data-explorer"
|
|
assert d["description"] == "A test skill."
|
|
# Parent plugin metadata surfaced for the redesigned hero / sidebar.
|
|
assert d["category"] == "Data"
|
|
assert d["marketplace_name"] # registry display name
|
|
assert d["parent_updated_at"] is not None
|
|
# Bundle + files.
|
|
assert d["bundle_size"] is not None and d["bundle_size"] > 0
|
|
names = {f["path"] for f in d["files"]}
|
|
assert "SKILL.md" in names
|
|
assert "REFERENCE.md" in names
|
|
|
|
def test_agent_detail_single_file(self, web_client, tmp_path):
|
|
user_id, cookies = _create_user(web_client, "alice@x.com")
|
|
_seed_curated_grant(user_id=user_id, marketplace="mkt-x", plugin="alpha")
|
|
_seed_curated_agent_on_disk(tmp_path, "mkt-x", "alpha", "incident-responder")
|
|
r = web_client.get(
|
|
"/api/marketplace/curated/mkt-x/alpha/agent/incident-responder",
|
|
cookies=cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
d = r.json()
|
|
assert d["kind"] == "agent"
|
|
# Agents are flat single-file .md → exactly one file entry.
|
|
assert len(d["files"]) == 1
|
|
assert d["files"][0]["path"] == "incident-responder.md"
|
|
assert d["bundle_size"] == d["files"][0]["size"]
|
|
|
|
|
|
class TestSafeJoinContainment:
|
|
"""Defense-in-depth unit tests for ``_safe_join`` — the helper backing
|
|
``_read_inner`` / ``curated_skill_detail`` / ``curated_agent_detail``.
|
|
|
|
The threat model is a curated marketplace's git mirror containing a
|
|
booby-trapped symlink (or a future regression in Starlette's ``[^/]+``
|
|
path-param regex letting ``..`` slip through). HTTP-level ``..`` tests
|
|
aren't useful — httpx normalizes ``..`` segments before they reach the
|
|
wire — so the guard is verified at the function boundary.
|
|
"""
|
|
|
|
def _plugin_root(self, tmp_path):
|
|
root = tmp_path / "marketplaces" / "mkt-x" / "plugins" / "alpha"
|
|
(root / "skills").mkdir(parents=True)
|
|
(root / "agents").mkdir(parents=True)
|
|
return root
|
|
|
|
def test_resolves_normal_skill_path(self, tmp_path):
|
|
from app.api.marketplace import _safe_join
|
|
root = self._plugin_root(tmp_path)
|
|
skill_dir = root / "skills" / "data-explorer"
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text("body", encoding="utf-8")
|
|
result = _safe_join(root, "skills", "data-explorer", "SKILL.md")
|
|
assert result is not None
|
|
assert result == (skill_dir / "SKILL.md").resolve()
|
|
|
|
def test_dotdot_segment_escaping_root_returns_none(self, tmp_path):
|
|
from app.api.marketplace import _safe_join
|
|
root = self._plugin_root(tmp_path)
|
|
# Plant a sibling plugin's file that `..` traversal would otherwise reach.
|
|
sibling = tmp_path / "marketplaces" / "mkt-x" / "plugins" / "beta"
|
|
sibling.mkdir(parents=True)
|
|
(sibling / "SECRET.md").write_text("cross-plugin secret", encoding="utf-8")
|
|
# /skills/../../beta/SECRET.md would resolve to the sibling's file.
|
|
assert _safe_join(root, "skills", "..", "..", "beta", "SECRET.md") is None
|
|
|
|
def test_symlink_outside_plugin_returns_none(self, tmp_path):
|
|
import os, sys
|
|
if sys.platform == "win32":
|
|
pytest.skip("Symlink creation requires elevated permissions on Windows")
|
|
from app.api.marketplace import _safe_join
|
|
root = self._plugin_root(tmp_path)
|
|
outside = tmp_path / "secrets" / "OTHER.md"
|
|
outside.parent.mkdir(parents=True)
|
|
outside.write_text("cross-plugin secret", encoding="utf-8")
|
|
# A curator-planted symlink inside skills/evil/ pointing outside the
|
|
# plugin tree must not resolve through the guard.
|
|
evil_dir = root / "skills" / "evil"
|
|
evil_dir.mkdir()
|
|
os.symlink(outside, evil_dir / "SKILL.md")
|
|
assert _safe_join(root, "skills", "evil", "SKILL.md") is None
|
|
|
|
def test_missing_file_returns_none(self, tmp_path):
|
|
from app.api.marketplace import _safe_join
|
|
root = self._plugin_root(tmp_path)
|
|
assert _safe_join(root, "skills", "nope", "SKILL.md") is None
|
|
|
|
def test_inner_endpoint_404s_on_symlink_escape(self, web_client, tmp_path):
|
|
"""End-to-end: the symlink containment check actually wires through
|
|
the HTTP endpoint to a 404 (not a leaked 200)."""
|
|
import os, sys
|
|
if sys.platform == "win32":
|
|
pytest.skip("Symlink creation requires elevated permissions on Windows")
|
|
user_id, cookies = _create_user(web_client, "alice@x.com")
|
|
_seed_curated_grant(user_id=user_id, marketplace="mkt-x", plugin="alpha")
|
|
outside = tmp_path / "secrets" / "OTHER.md"
|
|
outside.parent.mkdir(parents=True)
|
|
outside.write_text(
|
|
"---\nname: leaked\n---\ncross-plugin secret", encoding="utf-8",
|
|
)
|
|
evil_dir = (
|
|
tmp_path / "marketplaces" / "mkt-x" / "plugins" / "alpha"
|
|
/ "skills" / "evil"
|
|
)
|
|
evil_dir.mkdir(parents=True)
|
|
os.symlink(outside, evil_dir / "SKILL.md")
|
|
r = web_client.get(
|
|
"/api/marketplace/curated/mkt-x/alpha/skill/evil",
|
|
cookies=cookies,
|
|
)
|
|
assert r.status_code == 404, r.text
|
|
assert r.json()["detail"] == "skill_not_found"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Flea standalone detail — extended response shape
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFleaDetail:
|
|
def test_flea_skill_detail_populates_files_owner_install_count(
|
|
self, web_client,
|
|
):
|
|
_, cookies = _create_user(web_client, "alice@x.com")
|
|
# Upload a skill into the Store.
|
|
up = web_client.post(
|
|
"/api/store/entities",
|
|
files={"file": ("s.zip", _make_skill_zip("alpha"), "application/zip")},
|
|
data={"type": "skill"}, cookies=cookies,
|
|
)
|
|
assert up.status_code == 201, up.text
|
|
entity_id = up.json()["id"]
|
|
|
|
r = web_client.get(
|
|
f"/api/marketplace/flea/{entity_id}/detail", cookies=cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
d = r.json()
|
|
assert d["source"] == "flea"
|
|
assert d["entity_id"] == entity_id
|
|
# Files walked from disk.
|
|
assert isinstance(d["files"], list) and len(d["files"]) >= 1
|
|
# Friendly owner_display falls through to users.name (email local-part
|
|
# is the seeded `name` in _create_user → 'alice').
|
|
assert d["owner_display"] == "alice"
|
|
# install_count starts at 0; bumps after install/uninstall toggle.
|
|
assert d["install_count"] == 0
|
|
# docs is always a list (empty when uploader didn't ship any).
|
|
assert isinstance(d["docs"], list)
|