* 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>
363 lines
16 KiB
Python
363 lines
16 KiB
Python
"""Integration tests for /marketplace.zip and /marketplace/info.
|
|
|
|
v13: uses user_group_members + resource_grants (no PluginAccessRepository,
|
|
no users.groups JSON). Admin is a regular group for marketplace filtering —
|
|
no god-mode shortcut.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import zipfile
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
def _auth(token):
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
@pytest.fixture
|
|
def marketplace_env(e2e_env, monkeypatch):
|
|
"""Spin up the FastAPI app with two fake marketplaces populated on disk.
|
|
|
|
Populates:
|
|
- marketplace_registry with 2 slugs: 'mkt-a', 'mkt-b'
|
|
- marketplace_plugins with:
|
|
mkt-a: plug-x (v1.0)
|
|
mkt-b: plug-y (v2.0), plug-z (v3.0)
|
|
- DATA_DIR/marketplaces/<slug>/plugins/<plugin>/ with a tiny CLAUDE.md
|
|
- admin user in Admin group with grants for all 3 plugins
|
|
- analyst user in TestGroup with grant for plug-y only
|
|
- nogroups user (only Everyone, no grants)
|
|
"""
|
|
from app.main import create_app
|
|
from app.auth.jwt import create_access_token
|
|
from src.db import get_system_db
|
|
from src.repositories.users import UserRepository
|
|
from src.repositories.user_groups import UserGroupsRepository
|
|
from src.repositories.user_group_members import UserGroupMembersRepository
|
|
from src.repositories.resource_grants import ResourceGrantsRepository
|
|
|
|
data_dir = e2e_env["data_dir"]
|
|
|
|
# Plugin folders on disk — each ships a real .claude-plugin/plugin.json
|
|
# so the synth marketplace.json picks up the plugin's authoritative name
|
|
# (matches what real upstream marketplaces do, and exercises the
|
|
# manifest_name resolution path).
|
|
for slug, plug in [("mkt-a", "plug-x"), ("mkt-b", "plug-y"), ("mkt-b", "plug-z")]:
|
|
d = data_dir / "marketplaces" / slug / "plugins" / plug
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
(d / "CLAUDE.md").write_text(
|
|
f"# {plug}\nThis is {plug} from {slug}.\n", encoding="utf-8"
|
|
)
|
|
skills = d / "skills"
|
|
skills.mkdir()
|
|
(skills / "hello.md").write_text(f"skill for {plug}", encoding="utf-8")
|
|
(d / ".claude-plugin").mkdir()
|
|
(d / ".claude-plugin" / "plugin.json").write_text(
|
|
json.dumps({"name": plug, "version": "1.0"}), encoding="utf-8",
|
|
)
|
|
|
|
# DB setup
|
|
conn = get_system_db()
|
|
try:
|
|
t = datetime.now(timezone.utc)
|
|
conn.execute(
|
|
"INSERT INTO marketplace_registry (id, name, url, registered_at) "
|
|
"VALUES (?, ?, ?, ?), (?, ?, ?, ?)",
|
|
[
|
|
"mkt-a", "Market A", "https://example.test/a.git", t,
|
|
"mkt-b", "Market B", "https://example.test/b.git", t,
|
|
],
|
|
)
|
|
for slug, name, ver in [
|
|
("mkt-a", "plug-x", "1.0"),
|
|
("mkt-b", "plug-y", "2.0"),
|
|
("mkt-b", "plug-z", "3.0"),
|
|
]:
|
|
raw = {"name": name, "version": ver, "source": f"./plugins/{name}",
|
|
"description": f"{name} from {slug}"}
|
|
conn.execute(
|
|
"INSERT INTO marketplace_plugins (marketplace_id, name, version, raw, updated_at) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
[slug, name, ver, json.dumps(raw), t],
|
|
)
|
|
|
|
users = UserRepository(conn)
|
|
users.create(id="admin1", email="admin@test.local", name="Admin")
|
|
users.create(id="analyst1", email="analyst@test.local", name="Analyst")
|
|
users.create(id="nogroups1", email="nobody@test.local", name="Nobody")
|
|
|
|
# System groups are seeded by db.init_schema(); look them up.
|
|
ug = UserGroupsRepository(conn)
|
|
ug.ensure_system("Admin", "system")
|
|
ug.ensure_system("Everyone", "system")
|
|
|
|
admin_gid = conn.execute("SELECT id FROM user_groups WHERE name='Admin'").fetchone()[0]
|
|
|
|
# Create a custom group for the analyst
|
|
tg = ug.create(name="TestGroup", description="granted plug-y only")
|
|
test_group_gid = tg["id"]
|
|
|
|
# Assign memberships
|
|
ugm = UserGroupMembersRepository(conn)
|
|
ugm.add_member("admin1", admin_gid, source="system_seed")
|
|
ugm.add_member("analyst1", test_group_gid, source="admin")
|
|
# nogroups1 is only in Everyone (auto-membership, no explicit row needed)
|
|
|
|
# Grant plugins via resource_grants
|
|
rg = ResourceGrantsRepository(conn)
|
|
# Admin group gets all 3 plugins
|
|
rg.create(group_id=admin_gid, resource_type="marketplace_plugin", resource_id="mkt-a/plug-x")
|
|
rg.create(group_id=admin_gid, resource_type="marketplace_plugin", resource_id="mkt-b/plug-y")
|
|
rg.create(group_id=admin_gid, resource_type="marketplace_plugin", resource_id="mkt-b/plug-z")
|
|
# TestGroup gets only plug-y
|
|
rg.create(group_id=test_group_gid, resource_type="marketplace_plugin", resource_id="mkt-b/plug-y")
|
|
|
|
# Model B (v28+): grant alone is no longer enough — explicitly
|
|
# subscribe each user to every plugin they should see in the
|
|
# served set. Pre-v28 fixtures relied on the auto-included
|
|
# behavior; tests below still expect the same served sets, so we
|
|
# mirror those expectations with explicit subscriptions.
|
|
from src.repositories.user_curated_subscriptions import (
|
|
UserCuratedSubscriptionsRepository,
|
|
)
|
|
subs = UserCuratedSubscriptionsRepository(conn)
|
|
subs.subscribe("admin1", "mkt-a", "plug-x")
|
|
subs.subscribe("admin1", "mkt-b", "plug-y")
|
|
subs.subscribe("admin1", "mkt-b", "plug-z")
|
|
subs.subscribe("analyst1", "mkt-b", "plug-y")
|
|
finally:
|
|
conn.close()
|
|
|
|
# Tokens
|
|
admin_token = create_access_token("admin1", "admin@test.local")
|
|
analyst_token = create_access_token("analyst1", "analyst@test.local")
|
|
nogroups_token = create_access_token("nogroups1", "nobody@test.local")
|
|
|
|
app = create_app()
|
|
client = TestClient(app)
|
|
return {
|
|
"client": client,
|
|
"admin_token": admin_token,
|
|
"analyst_token": analyst_token,
|
|
"nogroups_token": nogroups_token,
|
|
"data_dir": data_dir,
|
|
}
|
|
|
|
|
|
def _read_zip(data: bytes) -> dict[str, bytes]:
|
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
|
return {n: zf.read(n) for n in zf.namelist()}
|
|
|
|
|
|
class TestMarketplaceInfo:
|
|
def test_admin_sees_all_plugins(self, marketplace_env):
|
|
c = marketplace_env["client"]
|
|
resp = c.get("/marketplace/info", headers=_auth(marketplace_env["admin_token"]))
|
|
assert resp.status_code == 200
|
|
info = resp.json()
|
|
# `name` in /marketplace/info mirrors what the synth manifest
|
|
# serves — the plugin's authoritative manifest_name (unprefixed
|
|
# in this fixture because plugin.json sets name=<plug>).
|
|
names = {p["name"] for p in info["plugins"]}
|
|
assert names == {"plug-x", "plug-y", "plug-z"}
|
|
# prefixed_name is exposed alongside so operators can still
|
|
# disambiguate a plugin's source marketplace.
|
|
prefixed = {p["prefixed_name"] for p in info["plugins"]}
|
|
assert prefixed == {"mkt-a-plug-x", "mkt-b-plug-y", "mkt-b-plug-z"}
|
|
assert "Admin" in info["groups"]
|
|
assert info["marketplace_name"] == "agnes"
|
|
assert info["plugin_count"] == 3
|
|
|
|
def test_analyst_sees_only_granted_plugin(self, marketplace_env):
|
|
c = marketplace_env["client"]
|
|
resp = c.get("/marketplace/info", headers=_auth(marketplace_env["analyst_token"]))
|
|
assert resp.status_code == 200
|
|
info = resp.json()
|
|
names = {p["name"] for p in info["plugins"]}
|
|
assert names == {"plug-y"}
|
|
assert "TestGroup" in info["groups"]
|
|
|
|
def test_user_with_no_groups_sees_empty_payload(self, marketplace_env):
|
|
"""Auto-Everyone removal: a user with zero memberships now sees an
|
|
empty groups list and zero plugins (no implicit Everyone fallback)."""
|
|
c = marketplace_env["client"]
|
|
resp = c.get("/marketplace/info", headers=_auth(marketplace_env["nogroups_token"]))
|
|
assert resp.status_code == 200
|
|
info = resp.json()
|
|
assert info["groups"] == []
|
|
assert info["plugins"] == []
|
|
|
|
def test_missing_auth_returns_401(self, marketplace_env):
|
|
c = marketplace_env["client"]
|
|
resp = c.get("/marketplace/info")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
class TestMarketplaceZip:
|
|
def test_admin_zip_contains_all_plugins_with_prefix(self, marketplace_env):
|
|
c = marketplace_env["client"]
|
|
resp = c.get("/marketplace.zip", headers=_auth(marketplace_env["admin_token"]))
|
|
assert resp.status_code == 200
|
|
assert resp.headers["content-type"] == "application/zip"
|
|
assert resp.headers["etag"].startswith('"') and resp.headers["etag"].endswith('"')
|
|
|
|
zip_contents = _read_zip(resp.content)
|
|
# Manifest at the expected path
|
|
assert ".claude-plugin/marketplace.json" in zip_contents
|
|
manifest = json.loads(zip_contents[".claude-plugin/marketplace.json"])
|
|
assert manifest["name"] == "agnes"
|
|
# `name` is the plugin's authoritative name from plugin.json — the
|
|
# fixture writes plugin.json with name=<plug>, so unprefixed.
|
|
names = {p["name"] for p in manifest["plugins"]}
|
|
assert names == {"plug-x", "plug-y", "plug-z"}
|
|
# source paths stay slug-prefixed so cross-marketplace dirs don't
|
|
# collide on disk in the flat ZIP / git tree layout.
|
|
sources = {p["source"] for p in manifest["plugins"]}
|
|
assert sources == {
|
|
"./plugins/mkt-a-plug-x",
|
|
"./plugins/mkt-b-plug-y",
|
|
"./plugins/mkt-b-plug-z",
|
|
}
|
|
# Files from every marketplace copied over
|
|
assert "plugins/mkt-a-plug-x/CLAUDE.md" in zip_contents
|
|
assert "plugins/mkt-b-plug-y/CLAUDE.md" in zip_contents
|
|
assert "plugins/mkt-b-plug-z/skills/hello.md" in zip_contents
|
|
# plugin.json is included in each plugin tree so Claude Code can
|
|
# resolve the loaded plugin's namespace from it.
|
|
assert "plugins/mkt-a-plug-x/.claude-plugin/plugin.json" in zip_contents
|
|
|
|
def test_analyst_zip_contains_only_granted(self, marketplace_env):
|
|
c = marketplace_env["client"]
|
|
resp = c.get("/marketplace.zip", headers=_auth(marketplace_env["analyst_token"]))
|
|
assert resp.status_code == 200
|
|
zip_contents = _read_zip(resp.content)
|
|
plugin_dirs = {p.split("/")[1] for p in zip_contents if p.startswith("plugins/")}
|
|
assert plugin_dirs == {"mkt-b-plug-y"}
|
|
|
|
def test_if_none_match_returns_304(self, marketplace_env):
|
|
c = marketplace_env["client"]
|
|
headers = _auth(marketplace_env["admin_token"])
|
|
first = c.get("/marketplace.zip", headers=headers)
|
|
etag = first.headers["etag"].strip('"')
|
|
second = c.get(
|
|
"/marketplace.zip",
|
|
headers={**headers, "If-None-Match": f'"{etag}"'},
|
|
)
|
|
assert second.status_code == 304
|
|
assert second.headers["etag"].strip('"') == etag
|
|
assert second.content == b""
|
|
|
|
def test_etag_changes_when_content_changes(self, marketplace_env):
|
|
from app.marketplace_server.packager import invalidate_etag_cache
|
|
|
|
c = marketplace_env["client"]
|
|
headers = _auth(marketplace_env["admin_token"])
|
|
first = c.get("/marketplace.zip", headers=headers)
|
|
etag1 = first.headers["etag"]
|
|
|
|
# Mutate a plugin file on disk → etag must change.
|
|
target = marketplace_env["data_dir"] / "marketplaces" / "mkt-a" / "plugins" / "plug-x" / "CLAUDE.md"
|
|
target.write_text("# plug-x\nMUTATED\n", encoding="utf-8")
|
|
|
|
# Invalidate the in-process ETag cache so the next request
|
|
# re-hashes from disk instead of returning the stale cached value.
|
|
invalidate_etag_cache()
|
|
|
|
second = c.get("/marketplace.zip", headers=headers)
|
|
etag2 = second.headers["etag"]
|
|
assert etag1 != etag2
|
|
|
|
def test_missing_auth_returns_401(self, marketplace_env):
|
|
c = marketplace_env["client"]
|
|
resp = c.get("/marketplace.zip")
|
|
assert resp.status_code == 401
|
|
|
|
# --- New tests for ETag + auth coverage ---
|
|
|
|
def test_zip_returns_correct_content_with_etag_header(self, marketplace_env):
|
|
"""GET /marketplace.zip returns ZIP body with a valid ETag header."""
|
|
c = marketplace_env["client"]
|
|
headers = _auth(marketplace_env["admin_token"])
|
|
resp = c.get("/marketplace.zip", headers=headers)
|
|
assert resp.status_code == 200
|
|
assert resp.headers["content-type"] == "application/zip"
|
|
etag = resp.headers["etag"]
|
|
assert etag.startswith('"') and etag.endswith('"')
|
|
# ETag is a 16-char hex string (sha256 prefix)
|
|
etag_val = etag.strip('"')
|
|
assert len(etag_val) == 16
|
|
# Body is a valid ZIP
|
|
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
|
|
assert ".claude-plugin/marketplace.json" in zf.namelist()
|
|
|
|
def test_if_none_match_returns_full_content_when_changed(self, marketplace_env):
|
|
"""GET /marketplace.zip with a stale If-None-Match returns full content."""
|
|
c = marketplace_env["client"]
|
|
headers = _auth(marketplace_env["admin_token"])
|
|
first = c.get("/marketplace.zip", headers=headers)
|
|
stale_etag = "0000000000000000" # definitely wrong
|
|
second = c.get(
|
|
"/marketplace.zip",
|
|
headers={**headers, "If-None-Match": f'"{stale_etag}"'},
|
|
)
|
|
assert second.status_code == 200
|
|
assert len(second.content) > 0
|
|
# The returned ETag is the real one, not the stale one
|
|
assert second.headers["etag"].strip('"') != stale_etag
|
|
|
|
def test_zip_requires_pat_authentication(self, marketplace_env):
|
|
"""GET /marketplace.zip without any auth returns 401."""
|
|
c = marketplace_env["client"]
|
|
resp = c.get("/marketplace.zip")
|
|
assert resp.status_code == 401
|
|
|
|
def test_zip_with_invalid_token_returns_401(self, marketplace_env):
|
|
"""GET /marketplace.zip with a garbage Bearer token returns 401."""
|
|
c = marketplace_env["client"]
|
|
resp = c.get("/marketplace.zip", headers={"Authorization": "Bearer invalid-token"})
|
|
assert resp.status_code == 401
|
|
|
|
def test_if_none_match_with_wrong_etag_returns_full_zip(self, marketplace_env):
|
|
"""If-None-Match with a non-matching ETag returns 200 + full ZIP."""
|
|
c = marketplace_env["client"]
|
|
headers = _auth(marketplace_env["admin_token"])
|
|
resp = c.get(
|
|
"/marketplace.zip",
|
|
headers={**headers, "If-None-Match": '"wrong-etag-value"'},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.headers["content-type"] == "application/zip"
|
|
|
|
def test_manifest_falls_back_when_plugin_json_missing(self, marketplace_env):
|
|
"""If a plugin's .claude-plugin/plugin.json is absent, the synth
|
|
manifest falls back to the upstream marketplace.json's plugin name
|
|
(= mp.name in DB)."""
|
|
from app.marketplace_server.packager import invalidate_etag_cache
|
|
|
|
c = marketplace_env["client"]
|
|
# Remove plug-x's plugin.json on disk
|
|
target = (
|
|
marketplace_env["data_dir"]
|
|
/ "marketplaces"
|
|
/ "mkt-a"
|
|
/ "plugins"
|
|
/ "plug-x"
|
|
/ ".claude-plugin"
|
|
/ "plugin.json"
|
|
)
|
|
target.unlink()
|
|
invalidate_etag_cache()
|
|
|
|
resp = c.get("/marketplace.zip", headers=_auth(marketplace_env["admin_token"]))
|
|
assert resp.status_code == 200
|
|
zip_contents = _read_zip(resp.content)
|
|
manifest = json.loads(zip_contents[".claude-plugin/marketplace.json"])
|
|
plug_x = next(p for p in manifest["plugins"] if p["source"] == "./plugins/mkt-a-plug-x")
|
|
assert plug_x["name"] == "plug-x"
|