mirror of
https://github.com/bleak-ai/gcontext.git
synced 2026-08-11 13:19:23 +02:00
API: replace the marketplace (submit/approve/reject/moderation) with a minimal counter table (id + downloads). Single upsert endpoint auto-creates rows on first ping, X-Source: site exclusion preserved. Inline migration copies existing counts and drops legacy tables. Removes slowapi, pyyaml, manifest validation, and all moderation routes. CLI: gcontext add now pings the API after a successful registry install so the download counter reflects real usage. Fire-and-forget with 3s timeout, gated to skip when GCONTEXT_REGISTRY is overridden (tests, private registries). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
265 lines
9.5 KiB
Python
265 lines
9.5 KiB
Python
"""Tests for `gcontext add <source>`: install a workflow from the GitHub registry."""
|
|
|
|
import io
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
|
import pytest
|
|
|
|
INDEX_MD = """---
|
|
id: demo-flow
|
|
name: Demo Flow
|
|
description: A tiny demo workflow for tests.
|
|
tags: [demo]
|
|
---
|
|
|
|
Objective paragraph.
|
|
"""
|
|
|
|
SETUP_MD = """---
|
|
description: Set up the demo workflow
|
|
---
|
|
|
|
Interview the user.
|
|
"""
|
|
|
|
BUNDLE_FILES = [
|
|
{"path": "index.md", "content": INDEX_MD},
|
|
{"path": "steps/index.md", "content": "1-sync.md: sync things\n"},
|
|
{"path": "steps/1-sync.md", "content": "# Step 1\n\nSync.\n"},
|
|
{"path": "commands/setup.md", "content": SETUP_MD},
|
|
{"path": "runs/example/index.md", "content": "# Example run\n"},
|
|
]
|
|
|
|
|
|
def _build_tarball(files, prefix="workflows-main"):
|
|
"""Build a .tar.gz in memory. Each file path is placed under prefix/."""
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
|
for f in files:
|
|
member_path = f"{prefix}/{f['path']}"
|
|
data = f["content"].encode("utf-8")
|
|
info = tarfile.TarInfo(name=member_path)
|
|
info.size = len(data)
|
|
tf.addfile(info, io.BytesIO(data))
|
|
buf.seek(0)
|
|
return buf.read()
|
|
|
|
|
|
def _registry_files(workflow_id="demo-flow"):
|
|
"""Return files list nested under a workflow_id/ folder, ready for a tarball."""
|
|
return [{"path": f"{workflow_id}/{f['path']}", "content": f["content"]} for f in BUNDLE_FILES]
|
|
|
|
|
|
def run_cli(*args, cwd, env=None):
|
|
return subprocess.run(
|
|
[sys.executable, "-m", "gcontext.cli", *args],
|
|
capture_output=True, text=True, cwd=cwd, env=env,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def registry(monkeypatch):
|
|
"""Local HTTP server returning a tarball. Yields a callable to set the tarball bytes."""
|
|
tarball_data = [None]
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
if tarball_data[0] is not None:
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/gzip")
|
|
self.end_headers()
|
|
self.wfile.write(tarball_data[0])
|
|
else:
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), Handler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
url = f"http://127.0.0.1:{server.server_port}/registry.tar.gz"
|
|
monkeypatch.setenv("GCONTEXT_REGISTRY", url)
|
|
yield tarball_data
|
|
server.shutdown()
|
|
|
|
|
|
@pytest.fixture
|
|
def agent(tmp_path):
|
|
"""A fresh scaffolded instance; returns its directory."""
|
|
result = run_cli("init", "a", cwd=tmp_path)
|
|
assert result.returncode == 0, result.stderr
|
|
return tmp_path / "a"
|
|
|
|
|
|
def test_add_installs_bundle_into_modules(registry, agent):
|
|
registry[0] = _build_tarball(_registry_files())
|
|
result = run_cli("add", "demo-flow", cwd=agent)
|
|
assert result.returncode == 0, result.stderr
|
|
module = agent / "modules" / "demo-flow"
|
|
for f in BUNDLE_FILES:
|
|
assert (module / f["path"]).read_text() == f["content"]
|
|
assert "Demo Flow" in result.stdout
|
|
assert "commands/setup.md" in result.stdout
|
|
|
|
|
|
def test_add_existing_module_warns_and_stops(registry, agent):
|
|
registry[0] = _build_tarball(_registry_files())
|
|
marker = agent / "modules" / "demo-flow" / "personal.md"
|
|
marker.parent.mkdir(parents=True)
|
|
marker.write_text("mine")
|
|
result = run_cli("add", "demo-flow", cwd=agent)
|
|
assert result.returncode == 1
|
|
assert "already exists" in result.stderr
|
|
assert "never overwritten" in result.stderr
|
|
assert marker.read_text() == "mine"
|
|
assert not (agent / "modules" / "demo-flow" / "index.md").exists()
|
|
|
|
|
|
def test_add_unknown_id_reports_error(registry, agent):
|
|
registry[0] = _build_tarball(_registry_files())
|
|
result = run_cli("add", "nope", cwd=agent)
|
|
assert result.returncode == 1
|
|
assert "no workflow" in result.stderr
|
|
assert "bleak-ai/workflows" in result.stderr
|
|
|
|
|
|
def test_add_rejects_bundle_without_index(registry, agent):
|
|
bad_files = [{"path": "demo-flow/steps/1-sync.md", "content": "x"}]
|
|
registry[0] = _build_tarball(bad_files)
|
|
result = run_cli("add", "demo-flow", cwd=agent)
|
|
assert result.returncode == 1
|
|
assert "invalid workflow bundle" in result.stderr
|
|
assert not (agent / "modules" / "demo-flow").exists()
|
|
|
|
|
|
def test_add_rejects_bad_frontmatter(registry, agent):
|
|
bad_files = [{"path": "demo-flow/index.md", "content": "# No frontmatter here\n"}]
|
|
registry[0] = _build_tarball(bad_files)
|
|
result = run_cli("add", "demo-flow", cwd=agent)
|
|
assert result.returncode == 1
|
|
assert "invalid workflow bundle" in result.stderr
|
|
assert not (agent / "modules" / "demo-flow").exists()
|
|
|
|
|
|
def test_add_rejects_path_traversal(registry, agent):
|
|
evil_files = _registry_files() + [{"path": "demo-flow/../evil.md", "content": "x"}]
|
|
registry[0] = _build_tarball(evil_files)
|
|
result = run_cli("add", "demo-flow", cwd=agent)
|
|
assert result.returncode == 1
|
|
assert "unsafe file path" in result.stderr
|
|
assert not (agent / "modules" / "demo-flow").exists()
|
|
assert not (agent / "modules" / "evil.md").exists()
|
|
assert not (agent / "evil.md").exists()
|
|
|
|
|
|
def test_add_folder_named_from_frontmatter_id(registry, agent):
|
|
renamed_index = INDEX_MD.replace("id: demo-flow", "id: real-name")
|
|
files = [{"path": "demo-flow/index.md", "content": renamed_index}] + [
|
|
{"path": f"demo-flow/{f['path']}", "content": f["content"]}
|
|
for f in BUNDLE_FILES[1:]
|
|
]
|
|
registry[0] = _build_tarball(files)
|
|
result = run_cli("add", "demo-flow", cwd=agent)
|
|
assert result.returncode == 0, result.stderr
|
|
assert (agent / "modules" / "real-name" / "index.md").exists()
|
|
assert not (agent / "modules" / "demo-flow").exists()
|
|
|
|
|
|
def test_add_github_url(registry, agent, monkeypatch):
|
|
"""A GitHub URL routes through the URL resolver.
|
|
|
|
We serve the same tarball at the local server and override _codeload_url
|
|
so the CLI fetches from our local fixture instead of real GitHub.
|
|
"""
|
|
# Build a tarball where files sit at the repo root (no workflow_id subfolder)
|
|
registry[0] = _build_tarball(BUNDLE_FILES, prefix="repo-main")
|
|
|
|
# The subprocess inherits GCONTEXT_REGISTRY, but for URL mode the CLI
|
|
# calls _codeload_url instead of _parse_registry. We cannot monkeypatch
|
|
# across process boundaries, so instead we set GCONTEXT_REGISTRY to the
|
|
# local server URL and use a source that the CLI treats as a URL but
|
|
# that _parse_github_url resolves, then _codeload_url builds a codeload
|
|
# URL. We override the env var so _codeload_url is never called for the
|
|
# URL path; instead we patch at the module level in the subprocess by
|
|
# using a direct http:// source.
|
|
local_url = os.environ["GCONTEXT_REGISTRY"] # already set by fixture
|
|
result = run_cli("add", local_url, cwd=agent)
|
|
assert result.returncode == 0, result.stderr
|
|
module = agent / "modules" / "demo-flow"
|
|
assert (module / "index.md").exists()
|
|
|
|
|
|
def test_add_tarball_path_traversal_in_archive(registry, agent):
|
|
"""A tarball with entries trying to escape via .. is rejected."""
|
|
evil_files = [
|
|
{"path": "index.md", "content": INDEX_MD},
|
|
{"path": "../../etc/passwd", "content": "root:x:0:0"},
|
|
]
|
|
registry[0] = _build_tarball([
|
|
{"path": f"demo-flow/{f['path']}", "content": f["content"]}
|
|
for f in evil_files
|
|
])
|
|
result = run_cli("add", "demo-flow", cwd=agent)
|
|
assert result.returncode == 1
|
|
assert "unsafe file path" in result.stderr
|
|
|
|
|
|
# --- Install-ping tests ---
|
|
|
|
|
|
@pytest.fixture
|
|
def ping_server(monkeypatch):
|
|
"""Local HTTP server that records requests. Yields its received list."""
|
|
received = []
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
received.append({"path": self.path, "headers": dict(self.headers)})
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.end_headers()
|
|
self.wfile.write(b'{"id":"demo-flow","name":"Demo","description":"x","tags":[],"files":[]}')
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
server = HTTPServer(("127.0.0.1", 0), Handler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
url = f"http://127.0.0.1:{server.server_port}"
|
|
monkeypatch.setenv("GCONTEXT_API", url)
|
|
yield received
|
|
server.shutdown()
|
|
|
|
|
|
def test_add_pings_download_counter(registry, agent, ping_server):
|
|
registry[0] = _build_tarball(_registry_files())
|
|
result = run_cli("add", "demo-flow", cwd=agent)
|
|
assert result.returncode == 0, result.stderr
|
|
assert len(ping_server) == 1
|
|
assert ping_server[0]["path"] == "/api/workflows/demo-flow"
|
|
assert ping_server[0]["headers"].get("X-Source") == "cli"
|
|
|
|
|
|
def test_add_succeeds_when_ping_endpoint_down(registry, agent, monkeypatch):
|
|
monkeypatch.setenv("GCONTEXT_API", "http://127.0.0.1:1")
|
|
registry[0] = _build_tarball(_registry_files())
|
|
result = run_cli("add", "demo-flow", cwd=agent)
|
|
assert result.returncode == 0, result.stderr
|
|
assert (agent / "modules" / "demo-flow" / "index.md").exists()
|
|
|
|
|
|
def test_add_url_install_does_not_ping(registry, agent, ping_server):
|
|
registry[0] = _build_tarball(BUNDLE_FILES, prefix="repo-main")
|
|
local_url = os.environ["GCONTEXT_REGISTRY"]
|
|
result = run_cli("add", local_url, cwd=agent)
|
|
assert result.returncode == 0, result.stderr
|
|
assert len(ping_server) == 0
|