* 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>
303 lines
12 KiB
Python
303 lines
12 KiB
Python
"""Integration tests for /marketplace.git/* (git smart-HTTP channel).
|
|
|
|
These tests exercise the endpoint via FastAPI TestClient using the
|
|
git smart-HTTP wire protocol (`GET /info/refs?service=git-upload-pack`)
|
|
rather than spawning a real `git clone` subprocess — cheaper to run, no
|
|
socket required, and avoids Windows/PATH git-binary flakiness on CI.
|
|
|
|
v13: uses user_group_members + resource_grants (no PluginAccessRepository,
|
|
no users.groups JSON). PAT auth via HTTP Basic where password = PAT.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
def _basic(username: str, password: str) -> str:
|
|
token = base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
return f"Basic {token}"
|
|
|
|
|
|
@pytest.fixture
|
|
def git_env(e2e_env, monkeypatch):
|
|
"""Identical setup to the ZIP fixture but returns raw PAT strings usable
|
|
as HTTP Basic passwords. A valid PAT requires a real row in
|
|
personal_access_tokens (the PAT resolver does a DB round-trip), so we
|
|
create two: one admin, one analyst with group membership via
|
|
user_group_members + resource_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.access_tokens import AccessTokenRepository
|
|
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 bare repo's 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")]:
|
|
d = data_dir / "marketplaces" / slug / "plugins" / plug
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
(d / "CLAUDE.md").write_text(f"# {plug}\n", encoding="utf-8")
|
|
(d / ".claude-plugin").mkdir()
|
|
(d / ".claude-plugin" / "plugin.json").write_text(
|
|
json.dumps({"name": plug, "version": "1.0"}), encoding="utf-8",
|
|
)
|
|
|
|
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"),
|
|
]:
|
|
raw = {"name": name, "version": ver, "source": f"./plugins/{name}"}
|
|
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")
|
|
|
|
# System groups
|
|
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 TestGroup for 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")
|
|
|
|
# Grant plugins via resource_grants
|
|
rg = ResourceGrantsRepository(conn)
|
|
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=test_group_gid, resource_type="marketplace_plugin", resource_id="mkt-b/plug-y")
|
|
|
|
# Model B (v27+): explicit subscriptions are required for plugins
|
|
# to enter the served set. Pre-existing tests below assume the
|
|
# admin sees both plugins and the analyst sees plug-y; mirror
|
|
# those expectations by subscribing both users.
|
|
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("analyst1", "mkt-b", "plug-y")
|
|
|
|
# Create real PAT rows so resolve_token_to_user passes.
|
|
token_repo = AccessTokenRepository(conn)
|
|
pats: dict[str, str] = {}
|
|
for uid, email, _role in [
|
|
("admin1", "admin@test.local", "admin"),
|
|
("analyst1", "analyst@test.local", "analyst"),
|
|
]:
|
|
tid = str(uuid.uuid4())
|
|
jwt = create_access_token(
|
|
uid, email, token_id=tid, typ="pat",
|
|
)
|
|
token_repo.create(
|
|
id=tid, user_id=uid, name=f"{uid}-pat",
|
|
token_hash=hashlib.sha256(jwt.encode()).hexdigest(),
|
|
prefix=tid.replace("-", "")[:8],
|
|
expires_at=None,
|
|
)
|
|
pats[uid] = jwt
|
|
finally:
|
|
conn.close()
|
|
|
|
app = create_app()
|
|
client = TestClient(app)
|
|
return {
|
|
"client": client,
|
|
"admin_pat": pats["admin1"],
|
|
"analyst_pat": pats["analyst1"],
|
|
"data_dir": data_dir,
|
|
}
|
|
|
|
|
|
class TestGitSmartHttp:
|
|
"""Verify the WSGI app at /marketplace.git responds to the git protocol."""
|
|
|
|
def test_missing_auth_returns_401(self, git_env):
|
|
c = git_env["client"]
|
|
resp = c.get("/marketplace.git/info/refs?service=git-upload-pack")
|
|
assert resp.status_code == 401
|
|
assert "basic realm" in resp.headers.get("www-authenticate", "").lower()
|
|
|
|
def test_bad_basic_password_returns_401(self, git_env):
|
|
c = git_env["client"]
|
|
resp = c.get(
|
|
"/marketplace.git/info/refs?service=git-upload-pack",
|
|
headers={"Authorization": _basic("x", "not-a-real-token")},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
def test_info_refs_returns_git_protocol(self, git_env):
|
|
"""Good PAT → dulwich serves `info/refs` with a pkt-line body."""
|
|
c = git_env["client"]
|
|
resp = c.get(
|
|
"/marketplace.git/info/refs?service=git-upload-pack",
|
|
headers={"Authorization": _basic("x", git_env["admin_pat"])},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.headers["content-type"] == "application/x-git-upload-pack-advertisement"
|
|
# First line of smart-HTTP advertisement: pkt-line "# service=git-upload-pack"
|
|
body = resp.content
|
|
assert b"# service=git-upload-pack" in body
|
|
# Should include a ref to main
|
|
assert b"refs/heads/main" in body
|
|
|
|
def test_cache_dir_populated_after_first_hit(self, git_env):
|
|
"""Hitting the endpoint materializes `${DATA_DIR}/marketplaces/git-cache/<etag>.git`."""
|
|
c = git_env["client"]
|
|
cache = git_env["data_dir"] / "marketplaces" / "git-cache"
|
|
assert not cache.exists() or not any(cache.iterdir())
|
|
|
|
resp = c.get(
|
|
"/marketplace.git/info/refs?service=git-upload-pack",
|
|
headers={"Authorization": _basic("x", git_env["admin_pat"])},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
# Exactly one bare repo must have appeared.
|
|
entries = [p for p in cache.iterdir() if p.is_dir() and p.name.endswith(".git")]
|
|
assert len(entries) == 1
|
|
# Name is the ETag (16 hex chars) + ".git"
|
|
assert len(entries[0].name) == 16 + len(".git")
|
|
|
|
def test_admin_and_analyst_get_different_repos(self, git_env):
|
|
"""Different RBAC views → different content hashes → different bare repos."""
|
|
c = git_env["client"]
|
|
cache = git_env["data_dir"] / "marketplaces" / "git-cache"
|
|
|
|
c.get(
|
|
"/marketplace.git/info/refs?service=git-upload-pack",
|
|
headers={"Authorization": _basic("x", git_env["admin_pat"])},
|
|
)
|
|
c.get(
|
|
"/marketplace.git/info/refs?service=git-upload-pack",
|
|
headers={"Authorization": _basic("x", git_env["analyst_pat"])},
|
|
)
|
|
|
|
entries = [p for p in cache.iterdir() if p.is_dir() and p.name.endswith(".git")]
|
|
assert len(entries) == 2
|
|
|
|
# --- New tests for git smart HTTP protocol coverage ---
|
|
|
|
def test_git_upload_pack_endpoint_requires_auth(self, git_env):
|
|
"""POST /marketplace.git/git-upload-pack requires HTTP Basic auth."""
|
|
c = git_env["client"]
|
|
resp = c.post("/marketplace.git/git-upload-pack")
|
|
assert resp.status_code == 401
|
|
|
|
def test_git_endpoints_require_http_basic_with_pat(self, git_env):
|
|
"""Git endpoints require HTTP Basic auth where password = PAT.
|
|
Bearer auth is not accepted for git endpoints."""
|
|
c = git_env["client"]
|
|
# Bearer auth should fail — git uses Basic
|
|
resp = c.get(
|
|
"/marketplace.git/info/refs?service=git-upload-pack",
|
|
headers={"Authorization": f"Bearer {git_env['admin_pat']}"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
def test_info_refs_with_valid_pat_returns_200(self, git_env):
|
|
"""GET /marketplace.git/info/refs with valid PAT returns git protocol response."""
|
|
c = git_env["client"]
|
|
resp = c.get(
|
|
"/marketplace.git/info/refs?service=git-upload-pack",
|
|
headers={"Authorization": _basic("x", git_env["admin_pat"])},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "git-upload-pack" in resp.headers["content-type"]
|
|
|
|
def test_analyst_sees_filtered_content_via_git(self, git_env):
|
|
"""Analyst with limited grants gets a different (smaller) repo than admin."""
|
|
c = git_env["client"]
|
|
cache = git_env["data_dir"] / "marketplaces" / "git-cache"
|
|
|
|
# Admin request
|
|
admin_resp = c.get(
|
|
"/marketplace.git/info/refs?service=git-upload-pack",
|
|
headers={"Authorization": _basic("x", git_env["admin_pat"])},
|
|
)
|
|
assert admin_resp.status_code == 200
|
|
|
|
# Analyst request
|
|
analyst_resp = c.get(
|
|
"/marketplace.git/info/refs?service=git-upload-pack",
|
|
headers={"Authorization": _basic("x", git_env["analyst_pat"])},
|
|
)
|
|
assert analyst_resp.status_code == 200
|
|
|
|
# Two different cache entries (different RBAC views)
|
|
entries = [p for p in cache.iterdir() if p.is_dir() and p.name.endswith(".git")]
|
|
assert len(entries) == 2
|
|
|
|
def test_bare_repo_manifest_uses_plugin_json_name(self, git_env):
|
|
"""The bare repo's .claude-plugin/marketplace.json must list each
|
|
plugin under the name declared in its own plugin.json (not the
|
|
slug-prefixed dir name). Otherwise Claude Code's /plugin UI can't
|
|
link the loaded plugin back to its catalog entry."""
|
|
from dulwich.repo import Repo
|
|
|
|
c = git_env["client"]
|
|
c.get(
|
|
"/marketplace.git/info/refs?service=git-upload-pack",
|
|
headers={"Authorization": _basic("x", git_env["admin_pat"])},
|
|
)
|
|
cache = git_env["data_dir"] / "marketplaces" / "git-cache"
|
|
bare = next(p for p in cache.iterdir() if p.is_dir() and p.name.endswith(".git"))
|
|
|
|
repo = Repo(str(bare))
|
|
try:
|
|
head = repo[repo.refs[b"HEAD"]]
|
|
tree = repo[head.tree]
|
|
# dulwich tree.items() yields TreeEntry tuples (path, mode, sha)
|
|
cp_entry = next(e for e in tree.items() if e.path == b".claude-plugin")
|
|
cp_subtree = repo[cp_entry.sha]
|
|
manifest_entry = next(
|
|
e for e in cp_subtree.items() if e.path == b"marketplace.json"
|
|
)
|
|
manifest = json.loads(repo[manifest_entry.sha].data.decode("utf-8"))
|
|
finally:
|
|
repo.close()
|
|
|
|
names = {p["name"] for p in manifest["plugins"]}
|
|
assert names == {"plug-x", "plug-y"}
|
|
sources = {p["source"] for p in manifest["plugins"]}
|
|
assert sources == {"./plugins/mkt-a-plug-x", "./plugins/mkt-b-plug-y"}
|