agnes-the-ai-analyst/tests/test_marketplace_server_zip.py
minasarustamyan d4ac84dd46
feat(rbac): drop dataset_permissions + users.role + is_public; v19 migration (#150)
* feat(rbac): drop dataset_permissions + access_requests + users.role + is_public; v19 migration

BREAKING. Sjednocení datové RBAC vrstvy do per-group resource_grants modelu.
Před PR byla legacy data RBAC vrstva (dataset_permissions + is_public bypass)
de-facto neaktivní — is_public neměl API/UI/CLI surface, default true znamenal
že can_access_table vždycky bypassl. Dnes každý non-admin přístup vyžaduje
explicitní resource_grants(group, "table", id) řádek.

Schema v18 → v19 (src/db.py:_v18_to_v19_finalize):
- DROP TABLE dataset_permissions, access_requests
- DROP COLUMN users.role (NULL artifact since v13)
- DROP COLUMN table_registry.is_public
- Drops přes table-rebuild idiom (rename → create new → INSERT … SELECT
  → drop old) kvůli DuckDB ALTER DROP COLUMN limitacím na tabulkách
  s historic FK constraints. INSERT picks intersection sloupců, takže
  test fixtures s minimal pre-v19 schemou migrate cleanly.

Runtime:
- src/rbac.py:can_access_table → deleguje na app.auth.access.can_access
- DatasetPermissionRepository, AccessRequestRepository smazány
- AGNES_ENABLE_TABLE_GRANTS env-gate v app/resource_types.py odstraněn
  (TABLE je unconditionally enabled)

API drop:
- app/api/permissions.py, app/api/access_requests.py celé soubory
- /admin/permissions web route + admin_permissions.html
- "Request Access" modal v catalog.html + locked-row UI
- ~10 if user.get("role") != "admin" checků nahrazeno (admin shortcut
  je uvnitř can_access_table)
- /api/settings: drop permissions field z GET; PUT /api/settings/dataset
  gate přepnut na can_access(user_id, "table", dataset, conn)

Auth:
- app/auth/jwt.py:create_access_token: drop role parametr (claim zmizí
  z nově vydávaných JWT; staré tokeny zůstávají valid, claim ignored)
- app/api/users.py: drop role z CreateUserRequest / UpdateUserRequest
  (admin promotion = explicit add to Admin group via memberships API)
- src/repositories/users.py: drop role z create() / update()

CLI:
- da admin set-role smazán → hard-fail s replacement command
- da admin add-user --role flag pryč
- da auth import-token --role flag pryč
- da auth whoami: drop "Role:" výpis
- cli/config.py:save_token: role parametr now optional, no longer written
  (back-compat se starými token.json soubory zachována — pole se ignoruje)

Tests:
- DELETE: test_permissions.py, test_permissions_api.py, test_access_requests_api.py
- REWRITE: test_access_control.py (resource_grants flow), test_rbac.py
  (can_access_table over resource_grants), test_journey_rbac.py
  (drop access-request flow), test_resource_types.py (drop env-gate
  tests, drop is_public from helpers), test_v2_*.py (drop role-based
  user dicts in favor of id-based + Admin group membership),
  test_settings_api.py (no permissions field, can_access gate)
- TRIVIAL: ~30 souborů — drop role="admin" arg z UserRepository.create
  a 3rd positional role z create_access_token
- NEW: test_v18_to_v19 migration test (test_db.py),
  test_can_access_table_no_implicit_public (test_rbac.py),
  test_admin_set_role_returns_hardfail (test_cli_admin.py)
- OpenAPI snapshot regenerated

Docs:
- CHANGELOG: BREAKING entry pod [Unreleased]
- CLAUDE.md: schema v18 → v19
- docs/architecture.md: schema table + RBAC sekce přepsána
- docs/auth-google-oauth.md: admin promotion přes da admin break-glass
- cli/skills/security.md: kompletně přepsáno na group-based model
- docs/TODO-rbac-data-enforcement.md: smazáno (TODO splněn)

Test results: 2363 passed, 19 failed. Zbývající failures jsou pre-existing
Windows-specific issues (fcntl, charset) nesouvisející s tímto PR —
ověřeno git stash pop.

Plan: ~/.claude/plans/floofy-coalescing-parnas.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(release): cut 0.27.0

---------

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
2026-04-30 22:02:16 +02:00

349 lines
15 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")
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"