"""Admin endpoints for the flea-market guardrail submissions surface. Covers: * Listing — non-admin gets 403, admin gets the table * Override — flips status + entity visibility, writes audit row * Retry — re-queues a review_error / blocked_llm submission * Delete — wipes both submission row and entity bundle * Override edge: inline-blocked submissions without an entity_id cannot be overridden (refused with 409) """ from __future__ import annotations import io import json import zipfile from pathlib import Path import pytest from argon2 import PasswordHasher from fastapi.testclient import TestClient from app.utils import get_store_dir from src.db import close_system_db, get_system_db from src.repositories.users import UserRepository @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() 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!"): 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 _create_admin(client, email="admin@x.com"): from tests.helpers.auth import grant_admin user_id, cookies = _create_user(client, email, password="AdminPass1!") conn = get_system_db() grant_admin(conn, user_id) conn.close() return user_id, cookies def _make_skill_zip(skill_name: str = "probe") -> bytes: buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: zf.writestr( f"{skill_name}/SKILL.md", f"---\nname: {skill_name}\ndescription: Use when staging a clean reference bundle for admin-review pipeline tests\n---\n\n" + ("Body that is intentionally long enough to clear quality thresholds. " * 6), ) return buf.getvalue() def _make_eval_skill_zip(skill_name: str = "bad") -> bytes: """A skill with a bash-eval script — guaranteed to fail static_security.""" buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: zf.writestr( f"{skill_name}/SKILL.md", f"---\nname: {skill_name}\ndescription: Use when staging a bundle that intentionally trips static-security review checks\n---\n\n" + ("Body. " * 50), ) zf.writestr(f"{skill_name}/run.sh", "#!/bin/sh\neval $1\n") return buf.getvalue() def _seed_quarantined_entity( user_id: str, user_email: str, skill_name: str = "quarantined", description: str = "Description seeded for tests — long enough to pass content checks.", *, status: str = "blocked_llm", static_findings=None, llm_summary: str = "test stub finding", ): """Seed a hidden flea entity + matching submission row + on-disk bundle, mimicking the post-LLM-review-blocked state. Inline failures (manifest, static-security, content) are now hard-rejected upstream and never create DB rows. Tests that previously triggered the v30 ``submission_blocked`` path by uploading a bad bundle must seed the quarantined state directly via this helper. The default status is ``blocked_llm`` — the only status path that still creates a hidden+pending entity. Returns ``(entity_id, submission_id)``. """ from src.repositories.store_entities import StoreEntitiesRepository from src.repositories.store_submissions import StoreSubmissionsRepository from src.store_naming import suffixed_name import uuid as _uuid entity_id = _uuid.uuid4().hex username = user_email.split("@")[0] store_dir = get_store_dir() entity_dir = store_dir / entity_id plugin_root = entity_dir / "plugin" skill_subdir = plugin_root / "skills" / suffixed_name(skill_name, username) skill_subdir.mkdir(parents=True, exist_ok=True) skill_md = skill_subdir / "SKILL.md" skill_md.write_text( f"---\nname: {suffixed_name(skill_name, username)}\n" f"description: {description}\n---\n\n" + ("Body content. " * 30), ) run_sh = skill_subdir / "run.sh" run_sh.write_text("#!/bin/sh\neval $1\n") # v1 seed dir so the version download / restore endpoints find it. v1_plugin = entity_dir / "versions" / "v1" / "plugin" v1_plugin.parent.mkdir(parents=True, exist_ok=True) import shutil as _shutil _shutil.copytree(plugin_root, v1_plugin) # Mirror the InlineResult.to_response_dict() shape that the runner # would have produced. Static findings are surfaced verbatim in the # quarantine banner template (_quarantine_banner.html). findings = static_findings if static_findings is not None else [ {"file": "run.sh", "line": 2, "category": "code_exec", "severity": "high", "reason": "shell eval expanding a variable", "snippet": "eval $1"}, ] inline_checks = { "manifest": {"status": "pass", "issues": []}, "static_security": {"status": "fail", "findings": findings}, "content": {"status": "pass", "issues": []}, "quality": {"status": "pass", "issues": []}, } conn = get_system_db() StoreEntitiesRepository(conn).create( id=entity_id, owner_user_id=user_id, owner_username=username, type="skill", name=skill_name, description=description, category=None, version="1.0.0", file_size=512, visibility_status="hidden", ) sub_id = StoreSubmissionsRepository(conn).create( submitter_id=user_id, submitter_email=user_email, type="skill", name=skill_name, version="1.0.0", status=status, entity_id=entity_id, inline_checks=inline_checks, llm_findings={"risk_level": "high", "summary": llm_summary, "findings": findings}, file_size=512, bundle_sha256="0" * 64, ) conn.close() return entity_id, sub_id # --------------------------------------------------------------------------- # /api/admin/store/submissions — listing # --------------------------------------------------------------------------- class TestAdminListing: def test_non_admin_forbidden(self, web_client): _, user_cookies = _create_user(web_client, "user@x.com") r = web_client.get("/api/admin/store/submissions", cookies=user_cookies) assert r.status_code == 403 def test_security_upload_creates_no_submission_row(self, web_client): """Static-security findings are hard-rejected — no submission row, no entity row, no bundle on disk. Replaces the v30 contract where inline failures landed in admin's queue at ``blocked_inline``. """ from src.repositories.store_entities import StoreEntitiesRepository from src.repositories.store_submissions import StoreSubmissionsRepository user_id, user_cookies = _create_user(web_client, "u@x.com") c = web_client.post( "/api/store/entities", files={"file": ("s.zip", _make_eval_skill_zip("bad"), "application/zip")}, data={"type": "skill"}, cookies=user_cookies, ) assert c.status_code == 422 detail = c.json()["detail"] assert detail["code"] == "security_blocked" # Findings are exposed inline so the wizard banner can render them. assert detail["checks"]["static_security"]["status"] == "fail" assert detail["checks"]["static_security"]["findings"] # No DB rows, no quarantined entity for the submitter to inspect. assert "submission_id" not in detail assert "entity_id" not in detail conn = get_system_db() items, _total = StoreSubmissionsRepository(conn).list_for_admin( submitter_id=user_id, ) assert items == [] ent_items, _ = StoreEntitiesRepository(conn).list(owner_user_id=user_id) assert ent_items == [] conn.close() # Admin queue is empty: no row was ever created. _, admin_cookies = _create_admin(web_client) r = web_client.get( "/api/admin/store/submissions", cookies=admin_cookies, ) assert r.status_code == 200 items = r.json()["items"] assert not any(s["submitter_id"] == user_id for s in items), ( "security_blocked upload must not surface in admin queue" ) def test_security_upload_emits_audit_log_entry(self, web_client): """A static-security rejection writes one ``store.upload.security_blocked`` audit_log row carrying the findings + sha256 + size. That row is the *only* trace of the attempt; admin can grep audit_log for repeated offenders. """ from src.repositories.audit import AuditRepository user_id, user_cookies = _create_user(web_client, "spammer@x.com") c = web_client.post( "/api/store/entities", files={"file": ("s.zip", _make_eval_skill_zip("audit"), "application/zip")}, data={"type": "skill"}, cookies=user_cookies, ) assert c.status_code == 422 assert c.json()["detail"]["code"] == "security_blocked" conn = get_system_db() rows, _cursor = AuditRepository(conn).query( user_id=user_id, action="store.upload.security_blocked", limit=10, ) conn.close() assert len(rows) == 1 params = rows[0].get("params") or {} if isinstance(params, str): params = json.loads(params) assert params.get("finding_count", 0) >= 1 assert params.get("bundle_sha256") assert params.get("submitter_email") == "spammer@x.com" def test_inline_validation_returns_validation_failed_code(self, web_client): """A bundle that survives pre-bake but fails ``content_check`` (description too short) goes through ``_reject_inline_or_continue`` and is rejected with the new two-tier response: 422, ``detail.code == 'validation_failed'``, populated ``detail.checks`` shape, NO submission row, NO entity row, and NO audit_log entry (validation-tier failures are operator-fixable, not forensically interesting). Distinct from ``test_validation_failure_creates_no_audit_trail`` below which exercises the pre-bake ``zip_missing_skill_md`` path — that one fails before inline checks ever run. """ from src.repositories.audit import AuditRepository from src.repositories.store_submissions import StoreSubmissionsRepository # Valid skill layout — pre-bake parses frontmatter, layout # check passes. But description is < 60 chars → content_check # fires inside run_inline_checks → _reject_inline_or_continue # returns validation_failed. buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: zf.writestr( "tiny/SKILL.md", "---\nname: tiny\ndescription: too short\n---\n\n" + ("Body text. " * 50), ) short_desc_zip = buf.getvalue() user_id, user_cookies = _create_user(web_client, "shortdesc@x.com") r = web_client.post( "/api/store/entities", files={"file": ("s.zip", short_desc_zip, "application/zip")}, data={"type": "skill"}, cookies=user_cookies, ) assert r.status_code == 422, r.text detail = r.json()["detail"] assert detail["code"] == "validation_failed", detail # Frontend wizard (humanizeError in store_upload.html) reads # detail.checks.{manifest,content,quality} — lock the shape. assert set(detail["checks"].keys()) == {"manifest", "content", "quality"} assert detail["checks"]["content"]["status"] != "pass" # Validation-tier failures must not produce DB rows or audit entries. conn = get_system_db() items, _total = StoreSubmissionsRepository(conn).list_for_admin( submitter_id=user_id, ) assert items == [] rows, _cursor = AuditRepository(conn).query( user_id=user_id, action_prefix="store.upload.", limit=10, ) conn.close() assert rows == [], ( "validation-tier rejection must not write audit_log entries" ) def test_validation_failure_creates_no_audit_trail(self, web_client): """A bundle that fails manifest validation (missing SKILL.md) is a fixable user error — no submission row, no entity row, and NO audit_log entry. The submitter just sees the wizard banner. """ from src.repositories.audit import AuditRepository from src.repositories.store_submissions import StoreSubmissionsRepository # Skill ZIP without the required SKILL.md — manifest_check fails. buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: zf.writestr("broken/notes.md", "no manifest here\n") bad_zip = buf.getvalue() user_id, user_cookies = _create_user(web_client, "validation@x.com") c = web_client.post( "/api/store/entities", files={"file": ("s.zip", bad_zip, "application/zip")}, data={"type": "skill"}, cookies=user_cookies, ) assert c.status_code == 422 # zip_missing_skill_md fires at metadata-extract (pre-bake), so # the response is a plain ``detail: "zip_missing_skill_md"`` — # but the contract under test is "no DB rows, no audit trail", # which is what we assert below. conn = get_system_db() items, _total = StoreSubmissionsRepository(conn).list_for_admin( submitter_id=user_id, ) assert items == [] rows, _cursor = AuditRepository(conn).query( user_id=user_id, action_prefix="store.upload.", limit=10, ) conn.close() assert rows == [], ( "validation-tier rejection must not write audit_log entries" ) # --------------------------------------------------------------------------- # Override # --------------------------------------------------------------------------- class TestAdminOverride: def test_override_blocked_llm_publishes_entity(self, web_client): """Manually stage a blocked_llm row + entity, then override — the entity must flip to visibility_status='approved' and the submission to 'overridden'.""" from src.repositories.store_entities import StoreEntitiesRepository from src.repositories.store_submissions import StoreSubmissionsRepository user_id, _ = _create_user(web_client, "submitter@x.com") conn = get_system_db() ents = StoreEntitiesRepository(conn) ents.create( id="ent-blk", owner_user_id=user_id, owner_username="submitter", type="skill", name="blocked-thing", description="x" * 30, category=None, version="1.0.0", file_size=10, visibility_status="pending", ) subs = StoreSubmissionsRepository(conn) sid = subs.create( submitter_id=user_id, submitter_email="submitter@x.com", type="skill", name="blocked-thing", version="1.0.0", status="blocked_llm", entity_id="ent-blk", llm_findings={"risk_level": "high", "summary": "exfil"}, ) conn.close() _, admin_cookies = _create_admin(web_client) r = web_client.post( f"/api/admin/store/submissions/{sid}/override", json={"reason": "false positive — internal-only constants"}, cookies=admin_cookies, ) assert r.status_code == 200, r.text conn = get_system_db() ent = StoreEntitiesRepository(conn).get("ent-blk") assert ent["visibility_status"] == "approved" sub = StoreSubmissionsRepository(conn).get(sid) assert sub["status"] == "overridden" assert sub["override_reason"].startswith("false positive") conn.close() def test_override_v2_edit_promotes_to_current(self, web_client, monkeypatch): """When an admin overrides a v2+ edit/restore submission, the entity must be promoted to that version — same end state as an LLM auto-approval. Pre-fix the override only flipped visibility, leaving entity.version_no at the prior approved version + live bundle bytes unchanged. Installers kept getting the old version.""" from pathlib import Path from app.utils import get_store_dir from src.repositories.store_entities import StoreEntitiesRepository from src.repositories.store_submissions import StoreSubmissionsRepository from src.store_guardrails.runner import run_llm_review user_id, user_cookies = _create_user(web_client, "v2over@x.com") # Phase 1: clean v1 upload (guardrails off by default in tests) → # entity approved at version_no=1. r = web_client.post( "/api/store/entities", files={"file": ("s.zip", _make_skill_zip("v2over"), "application/zip")}, data={"type": "skill", "description": ( "Use when verifying admin override on a v2+ edit " "promotes the entity to the overridden version " "across the deferred-promotion path." )}, cookies=user_cookies, ) assert r.status_code == 201, r.text eid = r.json()["id"] # Phase 2: flip guardrails on (LLM mocked to BLOCK), PUT v2. def mock_block(*args, **kwargs): return { "risk_level": "high", "summary": "mock block", "findings": [{"severity": "high", "category": "test", "file": "x", "explanation": "mock"}], "template_placeholders_found": 0, "reviewed_by_model": "mock-model", "error": None, } monkeypatch.setattr( "src.store_guardrails.llm_review.review_bundle", mock_block, ) monkeypatch.setattr( "app.api.store.get_guardrails_enabled", lambda: True, ) monkeypatch.setattr( "app.api.store.get_guardrails_llm_provider_ready", lambda: True, ) # Build a v2 zip inline (slightly different body so the hash diverges). import io as _io import zipfile as _zip buf = _io.BytesIO() with _zip.ZipFile(buf, "w") as zf: zf.writestr( "v2over/SKILL.md", "---\nname: v2over\ndescription: " "Use when verifying admin override v2 promote behaviour after edit\n---\n\n" + ("V2 BODY text that is intentionally different from v1. " * 8), ) r = web_client.put( f"/api/store/entities/{eid}", files={"file": ("v2.zip", buf.getvalue(), "application/zip")}, cookies=user_cookies, ) assert r.status_code == 200, r.text # Drive the BG review synchronously so v2 lands at blocked_llm. conn = get_system_db() v2_sub_id = StoreSubmissionsRepository(conn).latest_for_entity(eid)["id"] conn.close() run_llm_review( v2_sub_id, plugin_dir=Path(get_store_dir()) / eid / "versions" / "v2" / "plugin", conn_factory=get_system_db, api_key_loader=lambda: "sk-test", model_loader=lambda: "mock-model", ) conn = get_system_db() ent_before = StoreEntitiesRepository(conn).get(eid) sub_before = StoreSubmissionsRepository(conn).get(v2_sub_id) conn.close() # Pre-condition: blocked at v2, entity stayed at v1. assert sub_before["status"] == "blocked_llm" assert ent_before["version_no"] == 1 v1_hash = ent_before["version"] # Phase 3: admin overrides v2. Entity must promote to v2 and # the on-disk live bundle must reflect v2's bytes. _, admin_cookies = _create_admin(web_client) r = web_client.post( f"/api/admin/store/submissions/{v2_sub_id}/override", json={"reason": "false positive — verified clean offline"}, cookies=admin_cookies, ) assert r.status_code == 200, r.text conn = get_system_db() ent_after = StoreEntitiesRepository(conn).get(eid) conn.close() assert ent_after["version_no"] == 2, ( f"override must promote entity to v2; got version_no={ent_after['version_no']}" ) assert ent_after["version"] != v1_hash, ( "entity.version (hash) must move to v2 — stayed at v1 hash" ) # Live plugin/ dir must hold v2's bytes (compare to v2 source dir). v2_plugin = Path(get_store_dir()) / eid / "versions" / "v2" / "plugin" live_plugin = Path(get_store_dir()) / eid / "plugin" v2_files = sorted(p.name for p in v2_plugin.rglob("*") if p.is_file()) live_files = sorted(p.name for p in live_plugin.rglob("*") if p.is_file()) assert v2_files == live_files, ( f"live plugin/ must mirror v2 dir after override; " f"v2_files={v2_files} live_files={live_files}" ) def test_override_v1_initial_upload_no_promote(self, web_client): """Override on an initial v1 (no prior approved version) must still work: entity already at version_no=1, no promotion needed. Regression guard so the v2+ promote logic doesn't break v1 overrides (the audit log used to be the only signal here).""" from src.repositories.store_entities import StoreEntitiesRepository from src.repositories.store_submissions import StoreSubmissionsRepository user_id, _ = _create_user(web_client, "v1over@x.com") conn = get_system_db() ents = StoreEntitiesRepository(conn) ents.create( id="ent-v1-over", owner_user_id=user_id, owner_username="v1over", type="skill", name="v1-blocked", description="x" * 40, category=None, version="aaaaaaaaaaaaaaaa", file_size=10, visibility_status="pending", ) subs = StoreSubmissionsRepository(conn) sid = subs.create( submitter_id=user_id, submitter_email="v1over@x.com", type="skill", name="v1-blocked", version="aaaaaaaaaaaaaaaa", status="blocked_llm", entity_id="ent-v1-over", llm_findings={"risk_level": "high", "summary": "x"}, ) # Backfill the v1 history entry submission_id so the promote # loop has a target to find. ents.update_history_submission_id("ent-v1-over", 1, sid) conn.close() _, admin_cookies = _create_admin(web_client) r = web_client.post( f"/api/admin/store/submissions/{sid}/override", json={"reason": "false positive — internal-only constants"}, cookies=admin_cookies, ) assert r.status_code == 200, r.text conn = get_system_db() ent = StoreEntitiesRepository(conn).get("ent-v1-over") sub = StoreSubmissionsRepository(conn).get(sid) conn.close() assert ent["visibility_status"] == "approved" assert ent["version_no"] == 1, ( "v1 override must NOT trigger phantom promotion" ) assert sub["status"] == "overridden" def test_override_short_reason_rejected(self, web_client): from src.repositories.store_entities import StoreEntitiesRepository from src.repositories.store_submissions import StoreSubmissionsRepository user_id, _ = _create_user(web_client, "u@x.com") conn = get_system_db() StoreEntitiesRepository(conn).create( id="e1", owner_user_id=user_id, owner_username="u", type="skill", name="x", description="x" * 30, category=None, version="1.0.0", file_size=10, visibility_status="pending", ) sid = StoreSubmissionsRepository(conn).create( submitter_id=user_id, submitter_email="u@x.com", type="skill", name="x", version="1.0.0", status="blocked_llm", entity_id="e1", ) conn.close() _, admin_cookies = _create_admin(web_client) r = web_client.post( f"/api/admin/store/submissions/{sid}/override", json={"reason": "ok"}, # 2 chars < min_length=4 cookies=admin_cookies, ) assert r.status_code == 422 # --------------------------------------------------------------------------- # Delete # --------------------------------------------------------------------------- class TestAdminDelete: def test_delete_clears_submission_and_entity(self, web_client): from src.repositories.store_entities import StoreEntitiesRepository from src.repositories.store_submissions import StoreSubmissionsRepository user_id, _ = _create_user(web_client, "u@x.com") conn = get_system_db() StoreEntitiesRepository(conn).create( id="e2", owner_user_id=user_id, owner_username="u", type="skill", name="y", description="x" * 30, category=None, version="1.0.0", file_size=10, visibility_status="approved", ) sid = StoreSubmissionsRepository(conn).create( submitter_id=user_id, submitter_email="u@x.com", type="skill", name="y", version="1.0.0", status="approved", entity_id="e2", ) conn.close() _, admin_cookies = _create_admin(web_client) r = web_client.delete( f"/api/admin/store/submissions/{sid}", cookies=admin_cookies, ) assert r.status_code == 204, r.text conn = get_system_db() assert StoreEntitiesRepository(conn).get("e2") is None assert StoreSubmissionsRepository(conn).get(sid) is None conn.close() # --------------------------------------------------------------------------- # List filters + pagination # --------------------------------------------------------------------------- def _seed_submissions(submitters): """Stage rows directly through the repo. Returns list of submission ids in insertion order so tests can reference rows.""" from src.repositories.store_submissions import StoreSubmissionsRepository conn = get_system_db() ids = [] subs = StoreSubmissionsRepository(conn) for u_id, u_email, type_, name, version, status in submitters: ids.append(subs.create( submitter_id=u_id, submitter_email=u_email, type=type_, name=name, version=version, status=status, entity_id=f"ent-{u_id}-{name}", )) conn.close() return ids class TestAdminListFilters: def test_filter_by_submitter(self, web_client): u1, _ = _create_user(web_client, "alice@x.com") u2, _ = _create_user(web_client, "bob@x.com") _seed_submissions([ (u1, "alice@x.com", "skill", "thing", "1.0", "approved"), (u1, "alice@x.com", "skill", "other", "1.0", "blocked_llm"), (u2, "bob@x.com", "skill", "third", "1.0", "approved"), ]) _, admin_cookies = _create_admin(web_client) r = web_client.get(f"/api/admin/store/submissions?submitter={u1}", cookies=admin_cookies) assert r.status_code == 200 assert r.json()["total"] == 2 def test_filter_by_type(self, web_client): u, _ = _create_user(web_client, "u@x.com") _seed_submissions([ (u, "u@x.com", "skill", "a", "1.0", "approved"), (u, "u@x.com", "agent", "b", "1.0", "approved"), (u, "u@x.com", "plugin", "c", "1.0", "approved"), ]) _, admin_cookies = _create_admin(web_client) r = web_client.get("/api/admin/store/submissions?type=agent", cookies=admin_cookies) assert r.status_code == 200 assert r.json()["total"] == 1 assert r.json()["items"][0]["type"] == "agent" def test_invalid_type_400(self, web_client): _, admin_cookies = _create_admin(web_client) r = web_client.get("/api/admin/store/submissions?type=bogus", cookies=admin_cookies) assert r.status_code == 400 def test_filter_by_name_substring(self, web_client): u, _ = _create_user(web_client, "u@x.com") _seed_submissions([ (u, "u@x.com", "skill", "summarizer-pro", "1.0", "approved"), (u, "u@x.com", "skill", "summarizer-alpha", "1.0", "approved"), (u, "u@x.com", "skill", "totally-other", "1.0", "approved"), ]) _, admin_cookies = _create_admin(web_client) r = web_client.get("/api/admin/store/submissions?name=summarizer", cookies=admin_cookies) assert r.status_code == 200 assert r.json()["total"] == 2 def test_filter_by_version_substring(self, web_client): u, _ = _create_user(web_client, "u@x.com") _seed_submissions([ (u, "u@x.com", "skill", "a", "1.0.0", "approved"), (u, "u@x.com", "skill", "b", "1.0.1", "approved"), (u, "u@x.com", "skill", "c", "2.0.0", "approved"), ]) _, admin_cookies = _create_admin(web_client) r = web_client.get("/api/admin/store/submissions?version=1.0", cookies=admin_cookies) assert r.status_code == 200 assert r.json()["total"] == 2 def test_combined_filters(self, web_client): u1, _ = _create_user(web_client, "alice@x.com") u2, _ = _create_user(web_client, "bob@x.com") _seed_submissions([ (u1, "alice@x.com", "skill", "x", "1.0", "blocked_llm"), (u1, "alice@x.com", "agent", "y", "1.0", "blocked_llm"), (u2, "bob@x.com", "skill", "z", "1.0", "blocked_llm"), ]) _, admin_cookies = _create_admin(web_client) r = web_client.get( f"/api/admin/store/submissions?submitter={u1}&type=skill", cookies=admin_cookies, ) assert r.json()["total"] == 1 class TestAdminListPagination: def test_skip_limit_and_total(self, web_client): u, _ = _create_user(web_client, "u@x.com") _seed_submissions([ (u, "u@x.com", "skill", f"thing-{i}", "1.0", "approved") for i in range(7) ]) _, admin_cookies = _create_admin(web_client) r = web_client.get("/api/admin/store/submissions?limit=3&skip=0", cookies=admin_cookies) b = r.json() assert b["total"] == 7 and len(b["items"]) == 3 r = web_client.get("/api/admin/store/submissions?limit=3&skip=3", cookies=admin_cookies) b = r.json() assert b["total"] == 7 and len(b["items"]) == 3 r = web_client.get("/api/admin/store/submissions?limit=3&skip=6", cookies=admin_cookies) b = r.json() assert b["total"] == 7 and len(b["items"]) == 1 def test_limit_clamped(self, web_client): _, admin_cookies = _create_admin(web_client) r = web_client.get("/api/admin/store/submissions?limit=999999", cookies=admin_cookies) # Endpoint clamps to 500. No assertion on content; smoke test. assert r.status_code == 200 assert r.json()["limit"] == 500 # --------------------------------------------------------------------------- # Detail page # --------------------------------------------------------------------------- class TestAdminDetailPage: def test_detail_renders_for_existing_submission(self, web_client): u, _ = _create_user(web_client, "u@x.com") ids = _seed_submissions([ (u, "u@x.com", "skill", "thing", "1.0", "approved"), ]) _, admin_cookies = _create_admin(web_client) r = web_client.get(f"/admin/store/submissions/{ids[0]}", cookies=admin_cookies) assert r.status_code == 200 body = r.text assert ids[0] in body assert "Back to all submissions" in body def test_detail_404_on_missing(self, web_client): _, admin_cookies = _create_admin(web_client) r = web_client.get("/admin/store/submissions/does-not-exist", cookies=admin_cookies) assert r.status_code == 404 def test_detail_non_admin_forbidden(self, web_client): u, user_cookies = _create_user(web_client, "u@x.com") ids = _seed_submissions([ (u, "u@x.com", "skill", "thing", "1.0", "approved"), ]) r = web_client.get(f"/admin/store/submissions/{ids[0]}", cookies=user_cookies) assert r.status_code in (302, 401, 403) # --------------------------------------------------------------------------- # Rescan # --------------------------------------------------------------------------- def _stage_entity_with_bundle(tmp_root, owner_id, name, body=None): """Create a real on-disk plugin tree under DATA_DIR/store//plugin so the rescan endpoint sees a bundle to scan.""" from pathlib import Path from src.repositories.store_entities import StoreEntitiesRepository import uuid entity_id = uuid.uuid4().hex plugin_dir = Path(tmp_root) / "store" / entity_id / "plugin" / "skills" / name plugin_dir.mkdir(parents=True, exist_ok=True) (plugin_dir / "SKILL.md").write_text( body or ( "---\nname: " + name + "\ndescription: Use when staging a reference clean bundle so the admin rescan flow can re-run inline checks against it\n---\n\n" + ("Long body to satisfy quality and content guardrail thresholds. " * 8) ), encoding="utf-8", ) conn = get_system_db() StoreEntitiesRepository(conn).create( id=entity_id, owner_user_id=owner_id, owner_username=owner_id, type="skill", name=name, description="Use when staging an entity row so admin rescan can re-evaluate the on-disk bundle against the inline guardrail tier", category=None, version="1.0.0", file_size=10, visibility_status="approved", ) conn.close() return entity_id class TestAdminRescan: def test_rescan_clean_bundle_pending_llm(self, web_client, tmp_path): from src.repositories.store_submissions import StoreSubmissionsRepository u, _ = _create_user(web_client, "u@x.com") eid = _stage_entity_with_bundle(tmp_path, u, "rescan-clean") conn = get_system_db() sid = StoreSubmissionsRepository(conn).create( submitter_id=u, submitter_email="u@x.com", type="skill", name="rescan-clean", version="1.0.0", status="approved", entity_id=eid, ) conn.close() _, admin_cookies = _create_admin(web_client) r = web_client.post( f"/api/admin/store/submissions/{sid}/rescan", cookies=admin_cookies, ) assert r.status_code == 200, r.text # No ANTHROPIC_API_KEY in test env → guardrails disabled → auto-approved. assert r.json()["status"] in {"pending_llm", "approved"} conn = get_system_db() sub = StoreSubmissionsRepository(conn).get(sid) assert "retry_count" not in sub # v34: column dropped conn.close() def test_rescan_dirty_bundle_blocks_inline(self, web_client, tmp_path): """A bundle that introduces a static-security violation since the original review must rescan to blocked_inline.""" from src.repositories.store_entities import StoreEntitiesRepository from src.repositories.store_submissions import StoreSubmissionsRepository from pathlib import Path u, _ = _create_user(web_client, "u@x.com") eid = _stage_entity_with_bundle(tmp_path, u, "rescan-dirty") # Inject a bash-eval script — re-rescan must catch it. bad = Path(tmp_path) / "store" / eid / "plugin" / "skills" / "rescan-dirty" / "run.sh" bad.write_text("#!/bin/sh\neval $1\n", encoding="utf-8") conn = get_system_db() sid = StoreSubmissionsRepository(conn).create( submitter_id=u, submitter_email="u@x.com", type="skill", name="rescan-dirty", version="1.0.0", status="approved", entity_id=eid, ) conn.close() _, admin_cookies = _create_admin(web_client) r = web_client.post( f"/api/admin/store/submissions/{sid}/rescan", cookies=admin_cookies, ) assert r.status_code == 200, r.text assert r.json()["status"] == "blocked_inline" conn = get_system_db() ent = StoreEntitiesRepository(conn).get(eid) assert ent["visibility_status"] == "hidden" sub = StoreSubmissionsRepository(conn).get(sid) assert sub["status"] == "blocked_inline" # Static-security finding from the new bash-eval is captured. ic = sub["inline_checks"] assert ic["static_security"]["status"] == "fail" assert any(f["category"] == "code_exec" for f in ic["static_security"]["findings"]) conn.close() def test_rescan_without_entity_409(self, web_client): from src.repositories.store_submissions import StoreSubmissionsRepository u, _ = _create_user(web_client, "u@x.com") conn = get_system_db() sid = StoreSubmissionsRepository(conn).create( submitter_id=u, submitter_email="u@x.com", type="skill", name="x", version="1.0.0", status="blocked_inline", entity_id=None, ) conn.close() _, admin_cookies = _create_admin(web_client) r = web_client.post( f"/api/admin/store/submissions/{sid}/rescan", cookies=admin_cookies, ) assert r.status_code == 409 def test_rescan_missing_bundle_410(self, web_client): from src.repositories.store_submissions import StoreSubmissionsRepository u, _ = _create_user(web_client, "u@x.com") conn = get_system_db() sid = StoreSubmissionsRepository(conn).create( submitter_id=u, submitter_email="u@x.com", type="skill", name="x", version="1.0.0", status="approved", entity_id="missing-eid", ) conn.close() _, admin_cookies = _create_admin(web_client) r = web_client.post( f"/api/admin/store/submissions/{sid}/rescan", cookies=admin_cookies, ) assert r.status_code == 410 def test_rescan_non_admin_forbidden(self, web_client): from src.repositories.store_submissions import StoreSubmissionsRepository u, user_cookies = _create_user(web_client, "u@x.com") conn = get_system_db() sid = StoreSubmissionsRepository(conn).create( submitter_id=u, submitter_email="u@x.com", type="skill", name="x", version="1.0.0", status="approved", ) conn.close() r = web_client.post( f"/api/admin/store/submissions/{sid}/rescan", cookies=user_cookies, ) assert r.status_code == 403 class TestAdminRetryReviewsStagedBundle: """Codex adversarial finding [CRITICAL C1]: admin retry was passing live `plugin/` to the LLM. For a v2+ pending_llm / blocked_llm / review_error submission, live still holds the prior approved version. A retry would review the WRONG bytes and the runner's hash-match promotion would then advance the entity to staged bytes that were never reviewed. Fixed by resolving the staged `versions/v/plugin/` from the submission's version_history entry.""" def test_retry_v2_blocked_passes_staged_dir_not_live( self, web_client, monkeypatch, ): from pathlib import Path from app.utils import get_store_dir from src.repositories.store_entities import StoreEntitiesRepository from src.repositories.store_submissions import StoreSubmissionsRepository # v1 clean upload (guardrails off by default in tests). monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-fake-for-retry-test") user_id, user_cookies = _create_user(web_client, "retrystaged@x.com") r = web_client.post( "/api/store/entities", files={"file": ("s.zip", _make_skill_zip("retrystaged"), "application/zip")}, data={"type": "skill", "description": ( "Use when verifying retry reads staged version " "bundle bytes not live for v2 blocked submissions" )}, cookies=user_cookies, ) assert r.status_code == 201, r.text eid = r.json()["id"] # v2 PUT with guardrails on, LLM mocked to BLOCK → blocked_llm. def mock_block(*a, **kw): return { "risk_level": "high", "summary": "mock block", "findings": [{"severity": "high", "category": "test", "file": "x", "explanation": "mock"}], "template_placeholders_found": 0, "reviewed_by_model": "mock", "error": None, } monkeypatch.setattr( "src.store_guardrails.llm_review.review_bundle", mock_block, ) monkeypatch.setattr( "app.api.store.get_guardrails_enabled", lambda: True, ) monkeypatch.setattr( "app.api.store.get_guardrails_llm_provider_ready", lambda: True, ) import io as _io import zipfile as _zip buf = _io.BytesIO() with _zip.ZipFile(buf, "w") as zf: zf.writestr( "retrystaged/SKILL.md", "---\nname: retrystaged\ndescription: " "Use when verifying that admin retry hits the staged dir bundle bytes for v2 blocked submissions\n---\n\n" + ("V2-only body text that is intentionally long enough to clear the inline content-quality threshold. " * 4), ) r = web_client.put( f"/api/store/entities/{eid}", files={"file": ("v2.zip", buf.getvalue(), "application/zip")}, cookies=user_cookies, ) assert r.status_code == 200, r.text from src.store_guardrails.runner import run_llm_review conn = get_system_db() sub_id = StoreSubmissionsRepository(conn).latest_for_entity(eid)["id"] conn.close() run_llm_review( sub_id, plugin_dir=Path(get_store_dir()) / eid / "versions" / "v2" / "plugin", conn_factory=get_system_db, api_key_loader=lambda: "sk", model_loader=lambda: "mock", ) # Capture what review_bundle is called with on retry. seen = {} def spy_review_bundle(plugin_dir, **kw): seen["plugin_dir"] = plugin_dir return { "risk_level": "safe", "summary": "ok", "findings": [], "template_placeholders_found": 0, "reviewed_by_model": "mock", "error": None, "content_quality": {"verdict": "pass", "issues": []}, } monkeypatch.setattr( "src.store_guardrails.llm_review.review_bundle", spy_review_bundle, ) monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-fake-for-retry-loader") # Admin retry. _, admin_cookies = _create_admin(web_client) r = web_client.post( f"/api/admin/store/submissions/{sub_id}/retry", cookies=admin_cookies, ) assert r.status_code == 200, r.text v2_dir = Path(get_store_dir()) / eid / "versions" / "v2" / "plugin" live_dir = Path(get_store_dir()) / eid / "plugin" assert seen["plugin_dir"] == v2_dir, ( f"retry must review STAGED bytes ({v2_dir}); " f"instead reviewed {seen['plugin_dir']} (live={live_dir})" ) def test_rescan_v2_blocked_passes_staged_dir_not_live( self, web_client, monkeypatch, ): """Same invariant for rescan.""" from pathlib import Path from app.utils import get_store_dir from src.repositories.store_submissions import StoreSubmissionsRepository monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-fake-for-rescan-test") user_id, user_cookies = _create_user(web_client, "rescanstaged@x.com") r = web_client.post( "/api/store/entities", files={"file": ("s.zip", _make_skill_zip("rescanstaged"), "application/zip")}, data={"type": "skill", "description": ( "Use when verifying rescan reads staged version " "bundle bytes not live for v2 blocked submissions" )}, cookies=user_cookies, ) assert r.status_code == 201 eid = r.json()["id"] def mock_block(*a, **kw): return { "risk_level": "high", "summary": "mock block", "findings": [{"severity": "high", "category": "test", "file": "x", "explanation": "mock"}], "template_placeholders_found": 0, "reviewed_by_model": "mock", "error": None, } monkeypatch.setattr( "src.store_guardrails.llm_review.review_bundle", mock_block, ) monkeypatch.setattr( "app.api.store.get_guardrails_enabled", lambda: True, ) monkeypatch.setattr( "app.api.store.get_guardrails_llm_provider_ready", lambda: True, ) import io as _io import zipfile as _zip buf = _io.BytesIO() with _zip.ZipFile(buf, "w") as zf: zf.writestr( "rescanstaged/SKILL.md", "---\nname: rescanstaged\ndescription: " "Use when verifying that admin rescan hits the staged dir bundle bytes for v2 blocked submissions\n---\n\n" + ("V2 unique payload body line that is intentionally long enough to clear the inline content-quality threshold. " * 4), ) r = web_client.put( f"/api/store/entities/{eid}", files={"file": ("v2.zip", buf.getvalue(), "application/zip")}, cookies=user_cookies, ) assert r.status_code == 200, r.text from src.store_guardrails.runner import run_llm_review conn = get_system_db() sub_id = StoreSubmissionsRepository(conn).latest_for_entity(eid)["id"] conn.close() run_llm_review( sub_id, plugin_dir=Path(get_store_dir()) / eid / "versions" / "v2" / "plugin", conn_factory=get_system_db, api_key_loader=lambda: "sk", model_loader=lambda: "mock", ) # Spy on the inline pipeline. admin.py imports # `run_inline_checks` function-locally — patch at the source # module so the lookup at call time sees the spy. seen_inline: list = [] from src.store_guardrails import run_inline_checks as orig_run_inline def spy_inline(plugin_dir, **kw): seen_inline.append(plugin_dir) return orig_run_inline(plugin_dir, **kw) monkeypatch.setattr( "src.store_guardrails.run_inline_checks", spy_inline, ) monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-fake-for-rescan") _, admin_cookies = _create_admin(web_client) r = web_client.post( f"/api/admin/store/submissions/{sub_id}/rescan", cookies=admin_cookies, ) assert r.status_code == 200, r.text v2_dir = Path(get_store_dir()) / eid / "versions" / "v2" / "plugin" assert v2_dir in seen_inline, ( f"rescan must run inline against STAGED bytes ({v2_dir}); " f"got {seen_inline}" ) class TestOverrideForwardOnly: """Codex adversarial finding [HIGH H1]: override's promote loop used `target != current`, which would happily DEMOTE the live bundle when admin overrode a stale v2 submission while v3 was already approved + live. Forward-only: refuse the promote step when target_n <= current. Override of the row still flips status + visibility (admin's intent on the row itself is preserved); the on-disk live bundle just isn't rolled back.""" def test_override_stale_v2_does_not_demote_when_v3_current( self, web_client, monkeypatch, ): from src.repositories.store_entities import StoreEntitiesRepository from src.repositories.store_submissions import StoreSubmissionsRepository # v1 clean, v2 PUT (mocked block) → blocked_llm, entity stays v1. # Set a fake API key so the BG task `run_llm_review` (scheduled # by the API after each PUT) can pass `default_api_key_loader` # — it then calls the mocked `review_bundle`, not the network. monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-fake-for-override-test") user_id, user_cookies = _create_user(web_client, "demote@x.com") r = web_client.post( "/api/store/entities", files={"file": ("s.zip", _make_skill_zip("demote"), "application/zip")}, data={"type": "skill", "description": ( "Use when verifying override never demotes a " "currently-live newer version of the same entity" )}, cookies=user_cookies, ) assert r.status_code == 201 eid = r.json()["id"] def mock_block(*a, **kw): return { "risk_level": "high", "summary": "mock block", "findings": [{"severity": "high", "category": "test", "file": "x", "explanation": "mock"}], "template_placeholders_found": 0, "reviewed_by_model": "mock", "error": None, } monkeypatch.setattr( "src.store_guardrails.llm_review.review_bundle", mock_block, ) monkeypatch.setattr( "app.api.store.get_guardrails_enabled", lambda: True, ) monkeypatch.setattr( "app.api.store.get_guardrails_llm_provider_ready", lambda: True, ) # PUT v2 → blocked_llm. import io as _io import zipfile as _zip def _v2_zip(): buf = _io.BytesIO() with _zip.ZipFile(buf, "w") as zf: zf.writestr( "demote/SKILL.md", "---\nname: demote\ndescription: Use when v2 blocked path is exercised by admin override forward-only tests for the demote-protection invariant\n---\n\n" + ("V2 BODY content line long enough to clear the inline content-quality threshold for skill bodies. " * 4), ) return buf.getvalue() r = web_client.put( f"/api/store/entities/{eid}", files={"file": ("v2.zip", _v2_zip(), "application/zip")}, cookies=user_cookies, ) assert r.status_code == 200 from pathlib import Path from app.utils import get_store_dir from src.store_guardrails.runner import run_llm_review conn = get_system_db() v2_sub_id = StoreSubmissionsRepository(conn).latest_for_entity(eid)["id"] conn.close() run_llm_review( v2_sub_id, plugin_dir=Path(get_store_dir()) / eid / "versions" / "v2" / "plugin", conn_factory=get_system_db, api_key_loader=lambda: "sk", model_loader=lambda: "mock", ) # PUT v3 with mocked-APPROVE → v3 becomes current. def mock_approve(*a, **kw): return { "risk_level": "safe", "summary": "ok", "findings": [], "template_placeholders_found": 0, "reviewed_by_model": "mock", "error": None, "content_quality": {"verdict": "pass", "issues": []}, } monkeypatch.setattr( "src.store_guardrails.llm_review.review_bundle", mock_approve, ) def _v3_zip(): buf = _io.BytesIO() with _zip.ZipFile(buf, "w") as zf: zf.writestr( "demote/SKILL.md", "---\nname: demote\ndescription: Use when v3 approved path is exercised by admin override forward-only tests for the demote-protection invariant\n---\n\n" + ("V3 BODY content line long enough to clear the inline content-quality threshold for skill bodies. " * 4), ) return buf.getvalue() r = web_client.put( f"/api/store/entities/{eid}", files={"file": ("v3.zip", _v3_zip(), "application/zip")}, cookies=user_cookies, ) assert r.status_code == 200 conn = get_system_db() v3_sub_id = StoreSubmissionsRepository(conn).latest_for_entity(eid)["id"] conn.close() run_llm_review( v3_sub_id, plugin_dir=Path(get_store_dir()) / eid / "versions" / "v3" / "plugin", conn_factory=get_system_db, api_key_loader=lambda: "sk", model_loader=lambda: "mock", ) conn = get_system_db() ent_at_v3 = StoreEntitiesRepository(conn).get(eid) conn.close() assert ent_at_v3["version_no"] == 3, ( f"v3 must auto-promote to current; got {ent_at_v3['version_no']}" ) v3_hash = ent_at_v3["version"] # Admin overrides stale v2 (status was blocked_llm). MUST NOT # demote live from v3 back to v2 bytes. _, admin_cookies = _create_admin(web_client) r = web_client.post( f"/api/admin/store/submissions/{v2_sub_id}/override", json={"reason": "cleared in offline review — leaving stale row"}, cookies=admin_cookies, ) assert r.status_code == 200, r.text conn = get_system_db() ent_after = StoreEntitiesRepository(conn).get(eid) v2_sub_after = StoreSubmissionsRepository(conn).get(v2_sub_id) conn.close() assert ent_after["version_no"] == 3, ( f"override of stale v2 must NOT demote live; " f"got version_no={ent_after['version_no']} (expected 3)" ) assert ent_after["version"] == v3_hash, ( "entity.version hash must stay at v3 — got demoted" ) # Submission row still flips to overridden (admin intent on the # row itself is preserved; only the on-disk roll-back is gated). assert v2_sub_after["status"] == "overridden" def test_override_byte_identical_v2_blocked_promotes_correctly( self, web_client, monkeypatch, ): """Codex adversarial-review follow-up on PR #330: confirm override's submission_id lookup resolves v2 correctly when its hash collides with v1's. Pre-PR-330 the override loop did hash-match-first-wins → stuck on v1's n=1; forward-only `1 > 1` skipped promote.""" from pathlib import Path from app.utils import get_store_dir from src.repositories.store_entities import StoreEntitiesRepository from src.repositories.store_submissions import StoreSubmissionsRepository from src.store_guardrails.runner import run_llm_review import io as _io import zipfile as _zip monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-fake-for-override-identical") user_id, user_cookies = _create_user(web_client, "override-id@x.com") identical_body = ( "Identical body content long enough to clear the inline " "content-quality threshold for skill bodies. " * 4 ) def _identical_zip(): buf = _io.BytesIO() with _zip.ZipFile(buf, "w") as zf: zf.writestr( "overrideid/SKILL.md", "---\nname: overrideid\ndescription: " "Use when verifying override resolves v2 by " "submission_id even when v2 hash matches v1 hash\n---\n\n" + identical_body, ) return buf.getvalue() r = web_client.post( "/api/store/entities", files={"file": ("s.zip", _identical_zip(), "application/zip")}, data={"type": "skill", "description": ( "Use when verifying override resolves v2 by " "submission_id even when v2 hash matches v1 hash" )}, cookies=user_cookies, ) assert r.status_code == 201, r.text eid = r.json()["id"] monkeypatch.setattr( "app.api.store.get_guardrails_enabled", lambda: True, ) monkeypatch.setattr( "app.api.store.get_guardrails_llm_provider_ready", lambda: True, ) def mock_block(*a, **kw): return { "risk_level": "high", "summary": "mock block", "findings": [{"severity": "high", "category": "test", "file": "x", "explanation": "mock"}], "template_placeholders_found": 0, "reviewed_by_model": "mock", "error": None, } monkeypatch.setattr( "src.store_guardrails.llm_review.review_bundle", mock_block, ) r = web_client.put( f"/api/store/entities/{eid}", files={"file": ("v2.zip", _identical_zip(), "application/zip")}, cookies=user_cookies, ) assert r.status_code == 200, r.text conn = get_system_db() v2_sub_id = StoreSubmissionsRepository(conn).latest_for_entity(eid)["id"] conn.close() run_llm_review( v2_sub_id, plugin_dir=Path(get_store_dir()) / eid / "versions" / "v2" / "plugin", conn_factory=get_system_db, api_key_loader=lambda: "sk", model_loader=lambda: "mock", ) conn = get_system_db() ent_before = StoreEntitiesRepository(conn).get(eid) conn.close() assert ent_before["version_no"] == 1 v1_hash = ent_before["version"] _, admin_cookies = _create_admin(web_client) r = web_client.post( f"/api/admin/store/submissions/{v2_sub_id}/override", json={"reason": "false positive — cleared in offline review"}, cookies=admin_cookies, ) assert r.status_code == 200, r.text conn = get_system_db() ent_after = StoreEntitiesRepository(conn).get(eid) conn.close() assert ent_after["version_no"] == 2, ( f"override must promote to v2 even when v2 hash matches v1's; " f"got version_no={ent_after['version_no']}" ) # Hash unchanged (identical bundle), but version_no DID move. assert ent_after["version"] == v1_hash # --------------------------------------------------------------------------- # v30: Download bundle, Sort by size, Quota # --------------------------------------------------------------------------- class TestAdminBundleDownload: def test_download_returns_zip(self, web_client): """Live blocked-LLM bundle is downloadable as a fresh ZIP. Inline rejections no longer persist bundles, so this exercises the LLM path via the seed helper.""" user_id, _ = _create_user(web_client, "u@x.com") _entity_id, sub_id = _seed_quarantined_entity( user_id, "u@x.com", skill_name="dl", ) _, admin_cookies = _create_admin(web_client) r = web_client.get( f"/api/admin/store/submissions/{sub_id}/bundle.zip", cookies=admin_cookies, ) assert r.status_code == 200 assert r.headers["content-type"] == "application/zip" assert "attachment" in r.headers["content-disposition"] with zipfile.ZipFile(io.BytesIO(r.content)) as zf: assert any("SKILL.md" in n for n in zf.namelist()) assert any("run.sh" in n for n in zf.namelist()) def test_download_410_after_purge(self, web_client): from src.repositories.store_submissions import StoreSubmissionsRepository u, _ = _create_user(web_client, "u@x.com") conn = get_system_db() sub_id = StoreSubmissionsRepository(conn).create( submitter_id=u, submitter_email="u@x.com", type="skill", name="x", version="1.0.0", status="blocked_inline", entity_id=None, ) conn.close() _, admin_cookies = _create_admin(web_client) r = web_client.get( f"/api/admin/store/submissions/{sub_id}/bundle.zip", cookies=admin_cookies, ) assert r.status_code == 410 def test_download_non_admin_forbidden(self, web_client): from src.repositories.store_submissions import StoreSubmissionsRepository u, user_cookies = _create_user(web_client, "u@x.com") conn = get_system_db() sub_id = StoreSubmissionsRepository(conn).create( submitter_id=u, submitter_email="u@x.com", type="skill", name="x", version="1.0.0", status="approved", entity_id="some-id", ) conn.close() r = web_client.get( f"/api/admin/store/submissions/{sub_id}/bundle.zip", cookies=user_cookies, ) assert r.status_code == 403 def test_download_v2_blocked_returns_staged_bundle_not_live( self, web_client, monkeypatch, ): """Codex adversarial review [LOW]: pre-fix the download streamed live `plugin/` bytes regardless of which submission was being inspected. Under deferred promotion (v37+), live holds the prior approved version's bytes — so downloading a blocked v2 returned v1's safe bundle while the admin was deciding whether to override the *staged* v2's risky bytes. Fixed: resolve the staged `versions/v/plugin/` per submission via `_version_no_for_submission`.""" from pathlib import Path from app.utils import get_store_dir from src.repositories.store_entities import StoreEntitiesRepository from src.repositories.store_submissions import StoreSubmissionsRepository monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-fake-for-dl-test") user_id, user_cookies = _create_user(web_client, "dl-stage@x.com") r = web_client.post( "/api/store/entities", files={"file": ("s.zip", _make_skill_zip("dlstage"), "application/zip")}, data={"type": "skill", "description": ( "Use when verifying admin forensic download " "serves staged version bytes for v2 blocked " "submissions instead of the live prior version" )}, cookies=user_cookies, ) assert r.status_code == 201, r.text eid = r.json()["id"] # PUT v2 with mocked LLM block. def mock_block(*a, **kw): return { "risk_level": "high", "summary": "mock block", "findings": [{"severity": "high", "category": "test", "file": "x", "explanation": "mock"}], "template_placeholders_found": 0, "reviewed_by_model": "mock", "error": None, } monkeypatch.setattr( "src.store_guardrails.llm_review.review_bundle", mock_block, ) monkeypatch.setattr( "app.api.store.get_guardrails_enabled", lambda: True, ) monkeypatch.setattr( "app.api.store.get_guardrails_llm_provider_ready", lambda: True, ) import io as _io import zipfile as _zip v2_marker = "V2_STAGED_PAYLOAD_UNIQUE_TOKEN" buf = _io.BytesIO() with _zip.ZipFile(buf, "w") as zf: zf.writestr( "dlstage/SKILL.md", "---\nname: dlstage\ndescription: " "Use when verifying admin download returns staged bytes for v2 blocked submissions\n---\n\n" + (f"{v2_marker}. Body content long enough to clear the inline content-quality threshold for skill bodies. " * 3), ) r = web_client.put( f"/api/store/entities/{eid}", files={"file": ("v2.zip", buf.getvalue(), "application/zip")}, cookies=user_cookies, ) assert r.status_code == 200, r.text # Confirm v2 sub is blocked + entity stayed at v1 (live = v1 bytes). conn = get_system_db() v2_sub_id = StoreSubmissionsRepository(conn).latest_for_entity(eid)["id"] v2_sub = StoreSubmissionsRepository(conn).get(v2_sub_id) ent = StoreEntitiesRepository(conn).get(eid) conn.close() assert v2_sub["status"] == "blocked_llm" assert ent["version_no"] == 1 # Admin downloads the v2 submission's bundle. Must serve the # STAGED v2 bytes (contain v2_marker), NOT live v1 (which # doesn't). _, admin_cookies = _create_admin(web_client) r = web_client.get( f"/api/admin/store/submissions/{v2_sub_id}/bundle.zip", cookies=admin_cookies, ) assert r.status_code == 200 with zipfile.ZipFile(io.BytesIO(r.content)) as zf: blob = b"" for n in zf.namelist(): blob += zf.read(n) assert v2_marker.encode() in blob, ( "admin download must return STAGED v2 bytes " "(missing the v2-only marker — got live v1 bytes instead)" ) class TestAdminSortBySize: def test_sort_file_size_asc_desc(self, web_client): from src.repositories.store_submissions import StoreSubmissionsRepository u, _ = _create_user(web_client, "u@x.com") conn = get_system_db() StoreSubmissionsRepository(conn).create( submitter_id=u, submitter_email="u@x.com", type="skill", name="big", version="1", status="approved", file_size=10000, ) StoreSubmissionsRepository(conn).create( submitter_id=u, submitter_email="u@x.com", type="skill", name="med", version="1", status="approved", file_size=5000, ) StoreSubmissionsRepository(conn).create( submitter_id=u, submitter_email="u@x.com", type="skill", name="tiny", version="1", status="approved", file_size=100, ) conn.close() # Admin endpoint passes sort/order through to the repo whitelist # (#23). Confirm both directions via the API. _, admin_cookies = _create_admin(web_client) r = web_client.get( "/api/admin/store/submissions?sort=file_size&order=asc", cookies=admin_cookies, ) assert r.status_code == 200 names = [i["name"] for i in r.json()["items"]] assert names == ["tiny", "med", "big"], names r = web_client.get( "/api/admin/store/submissions?sort=file_size&order=desc", cookies=admin_cookies, ) assert r.status_code == 200 names = [i["name"] for i in r.json()["items"]] assert names == ["big", "med", "tiny"], names def test_invalid_sort_key_400(self, web_client): """#23 — sort whitelist rejects bogus keys at the API edge. Pre-fix, an unknown key fell through to a substring-replace chain that could surface 500s; now it's a clean 400.""" _, admin_cookies = _create_admin(web_client) r = web_client.get( "/api/admin/store/submissions?sort=injected__column", cookies=admin_cookies, ) assert r.status_code == 400, r.text assert "invalid_sort_key" in r.text class TestQuota: def test_quota_blocks_after_threshold(self, web_client, monkeypatch): """Quota gate triggers on the LLM-tier reject count. Inline failures no longer create rows, so the quota is seeded via the repo (mimicking two prior blocked_llm verdicts in the last 24h). The third upload is gated upstream by 429.""" from app import instance_config as ic from src.repositories.store_submissions import StoreSubmissionsRepository monkeypatch.setattr(ic, "get_guardrails_blocked_quota_per_day", lambda: 2) user_id, user_cookies = _create_user(web_client, "spammer@x.com") conn = get_system_db() repo = StoreSubmissionsRepository(conn) for i in range(2): repo.create( submitter_id=user_id, submitter_email="spammer@x.com", type="skill", name=f"seed-{i}", version="1.0.0", status="blocked_llm", entity_id=None, ) conn.close() # Third upload — any clean ZIP would do; expect 429 before the # guardrail pipeline runs. r = web_client.post( "/api/store/entities", files={"file": ("s.zip", _make_skill_zip("clean-after-quota"), "application/zip")}, data={"type": "skill"}, cookies=user_cookies, ) assert r.status_code == 429, r.text body = r.json()["detail"] assert body["code"] == "quota_exceeded" assert body["limit"] == 2 def test_quota_disabled_with_zero(self, web_client, monkeypatch): """quota=0 disables the gate entirely. Seed many blocked_llm rows; clean uploads still succeed.""" from app import instance_config as ic from src.repositories.store_submissions import StoreSubmissionsRepository monkeypatch.setattr(ic, "get_guardrails_blocked_quota_per_day", lambda: 0) user_id, user_cookies = _create_user(web_client, "trusted@x.com") conn = get_system_db() for i in range(5): StoreSubmissionsRepository(conn).create( submitter_id=user_id, submitter_email="trusted@x.com", type="skill", name=f"history-{i}", version="1.0.0", status="blocked_llm", entity_id=None, ) conn.close() r = web_client.post( "/api/store/entities", files={"file": ("s.zip", _make_skill_zip("clean-zero-quota"), "application/zip")}, data={"type": "skill"}, cookies=user_cookies, ) # Clean upload — passes inline guardrails. With ANTHROPIC_API_KEY # absent in tests the guardrail pipeline auto-disables so the # entity lands at ``approved`` (201). Assert the success codes # explicitly so a 500 from an unrelated regression doesn't # masquerade as quota-disabled. assert r.status_code in (200, 201), r.text def test_quota_counter_includes_blocked_llm_and_review_error(self, web_client): """The counter narrows to ``blocked_llm`` + ``review_error`` — inline failures no longer create rows. Legacy ``blocked_inline`` rows from pre-cutover instances are intentionally excluded (kept in DB as historical audit, not counted toward the live quota).""" from datetime import datetime, timezone, timedelta from src.repositories.store_submissions import StoreSubmissionsRepository _, user_cookies = _create_user(web_client, "spammer-9@x.com") conn = get_system_db() repo = StoreSubmissionsRepository(conn) for i, status in enumerate(("blocked_inline", "blocked_llm", "review_error")): repo.create( submitter_id="spammer-9", submitter_email="spammer-9@x.com", type="skill", name=f"q9-{i}", version="1.0.0", status=status, entity_id=None, inline_checks={"manifest": {"status": "fail"}}, ) since = datetime.now(timezone.utc) - timedelta(hours=24) count = repo.count_blocked_for_submitter_since("spammer-9", since) conn.close() assert count == 2, ( f"counter must skip legacy blocked_inline; got {count}" ) # --------------------------------------------------------------------------- # v32+ quarantine semantics # --------------------------------------------------------------------------- class TestQuarantineGates: def test_owner_cannot_delete_quarantined(self, web_client): """Owner trying to DELETE their own quarantined (blocked_llm) entity must be refused — admin investigates first.""" user_id, user_cookies = _create_user(web_client, "u@x.com") entity_id, _sub_id = _seed_quarantined_entity(user_id, "u@x.com", "q1") r = web_client.delete( f"/api/store/entities/{entity_id}", cookies=user_cookies, ) assert r.status_code == 403 body = r.json()["detail"] assert body["code"] == "quarantined_owner_cannot_delete" def test_admin_can_delete_quarantined(self, web_client): user_id, _ = _create_user(web_client, "u@x.com") entity_id, _sub_id = _seed_quarantined_entity(user_id, "u@x.com", "q2") _, admin_cookies = _create_admin(web_client) r = web_client.delete( f"/api/store/entities/{entity_id}", cookies=admin_cookies, ) # DELETE returns 204 No Content per the API design rule landed in # this PR (tests/test_api_design_rules.py rule 2). assert r.status_code == 204, r.text def test_non_owner_non_admin_cannot_view_quarantined(self, web_client): """Random user navigating to ANY per-entity asset endpoint gets 404 — same as if the entity didn't exist (no leak via 403). Covers every ``_enforce_visibility`` caller in app/api/store.py + the marketplace flea detail.""" owner_id, _ = _create_user(web_client, "owner@x.com") entity_id, _sub_id = _seed_quarantined_entity(owner_id, "owner@x.com", "q3") _, intruder_cookies = _create_user(web_client, "snoop@x.com") r = web_client.get( f"/api/store/entities/{entity_id}", cookies=intruder_cookies, ) assert r.status_code == 404, "detail must 404 for non-owner" r = web_client.get( f"/api/store/entities/{entity_id}/files", cookies=intruder_cookies, ) assert r.status_code == 404, "files must 404 for non-owner" r = web_client.get( f"/api/store/entities/{entity_id}/photo", cookies=intruder_cookies, ) assert r.status_code == 404, "photo must 404 for non-owner" r = web_client.get( f"/api/store/entities/{entity_id}/docs/anything.md", cookies=intruder_cookies, ) assert r.status_code == 404, "docs must 404 for non-owner" def test_quarantined_entity_excluded_from_store_entities_list(self, web_client): """Random non-owner non-admin hitting the public flea-listing (`/api/store/entities`) must NOT see another user's quarantined entry. Mirrors the marketplace-items coverage but on the store-namespaced listing.""" owner_id, owner_cookies = _create_user(web_client, "qowner@x.com") entity_id, _sub_id = _seed_quarantined_entity( owner_id, "qowner@x.com", "q-list", ) _, intruder_cookies = _create_user(web_client, "qsnoop@x.com") r = web_client.get("/api/store/entities", cookies=intruder_cookies) assert r.status_code == 200 ids = {it["id"] for it in r.json().get("items", [])} assert entity_id not in ids, ( "non-owner non-admin saw another user's quarantined entity " "in /api/store/entities listing" ) r = web_client.get("/api/store/entities", cookies=owner_cookies) owner_ids = {it["id"] for it in r.json().get("items", [])} assert entity_id in owner_ids, ( "owner should see own quarantined entity in their listing" ) def test_owner_can_view_their_quarantined_entity(self, web_client): owner_id, owner_cookies = _create_user(web_client, "owner@x.com") entity_id, _sub_id = _seed_quarantined_entity(owner_id, "owner@x.com", "q4") r = web_client.get( f"/api/store/entities/{entity_id}", cookies=owner_cookies, ) assert r.status_code == 200 def test_install_quarantined_refused_for_non_admin(self, web_client): """Even owner cannot add their own quarantined item to my-stack.""" owner_id, owner_cookies = _create_user(web_client, "owner@x.com") entity_id, _sub_id = _seed_quarantined_entity(owner_id, "owner@x.com", "q5") r = web_client.post( f"/api/store/entities/{entity_id}/install", cookies=owner_cookies, ) assert r.status_code == 409 assert r.json()["detail"] == "entity_not_approved" # --------------------------------------------------------------------------- # v32+ marketplace consolidation: visibility gates + owner-visible cards # --------------------------------------------------------------------------- class TestMarketplaceFleaConsolidation: def test_marketplace_flea_detail_404_for_non_owner_quarantined(self, web_client): """Random non-owner non-admin pasting an entity_id into /marketplace/flea/{id} gets 404 — same policy as the now-deleted /store/{id}.""" owner_id, _ = _create_user(web_client, "owner@x.com") eid, _sub_id = _seed_quarantined_entity(owner_id, "owner@x.com", "c1") _, intruder_cookies = _create_user(web_client, "snoop@x.com") r = web_client.get(f"/marketplace/flea/{eid}", cookies=intruder_cookies) assert r.status_code == 404 r = web_client.get(f"/api/marketplace/flea/{eid}/detail", cookies=intruder_cookies) assert r.status_code == 404 def test_marketplace_flea_detail_owner_sees_quarantine_banner(self, web_client): """Owner landing on /marketplace/flea/{id} sees the quarantine banner with the failure summary AND the actual finding details — not just a generic "Quarantined" header.""" owner_id, owner_cookies = _create_user(web_client, "owner@x.com") eid, _sub_id = _seed_quarantined_entity( owner_id, "owner@x.com", "c2", llm_summary="reviewer flagged the bash eval", static_findings=[ {"file": "run.sh", "line": 2, "severity": "high", "category": "code_exec", "reason": "shell eval expanding a variable", "explanation": "shell eval expanding a variable", "snippet": "eval $1"}, ], ) r = web_client.get(f"/marketplace/flea/{eid}", cookies=owner_cookies) assert r.status_code == 200 body = r.text assert "vis-banner" in body assert "Quarantined" in body # blocked_llm path renders the LLM verdict summary + per-finding # list. Banner must surface BOTH so the submitter knows WHY # without having to ping an admin. assert "Security findings" in body, ( "banner missing 'Security findings' section" ) assert "run.sh" in body, "banner missing path of offending file" assert "shell eval" in body, "banner missing reviewer summary" def test_review_error_banner_shows_error_detail(self, web_client): """#review_error — banner must surface the underlying error message + any inline_checks the runner captured before bailing. Pre-fix the banner only said 'couldn't complete its check' with no actionable detail.""" from src.repositories.store_entities import StoreEntitiesRepository from src.repositories.store_submissions import StoreSubmissionsRepository owner_id, owner_cookies = _create_user(web_client, "rev-err@x.com") # Stage entity + submission directly so we can land in review_error. conn = get_system_db() StoreEntitiesRepository(conn).create( id="ent-rev-err", owner_user_id=owner_id, owner_username="rev-err", type="skill", name="rev-err", description="Test review_error banner", category=None, version="1.0.0", file_size=10, visibility_status="hidden", ) StoreSubmissionsRepository(conn).create( submitter_id=owner_id, submitter_email="rev-err@x.com", type="skill", name="rev-err", version="1.0.0", status="review_error", entity_id="ent-rev-err", inline_checks={ "manifest": {"status": "pass", "issues": []}, "static_security": {"status": "pass", "findings": []}, "quality": {"status": "pass", "issues": [], "template_placeholders": 0}, }, llm_findings={ "risk_level": None, "summary": None, "findings": [], "template_placeholders_found": 0, "reviewed_by_model": None, "error": "LLMTimeoutError: Anthropic connection error", }, ) conn.close() r = web_client.get( "/marketplace/flea/ent-rev-err", cookies=owner_cookies, ) assert r.status_code == 200 assert "vis-banner" in r.text assert "errored" in r.text or "Under review" in r.text # The actionable error detail must surface. assert "LLMTimeoutError" in r.text, ( "review_error banner must surface llm_findings.error" ) def test_legacy_store_detail_url_returns_404(self, web_client): """The /store/{id} route was deleted in v32+. Stale bookmarks 404.""" _, owner_cookies = _create_user(web_client, "u@x.com") c = web_client.post( "/api/store/entities", files={"file": ("s.zip", _make_skill_zip("legacy"), "application/zip")}, data={"type": "skill"}, cookies=owner_cookies, ) eid = c.json()["id"] r = web_client.get(f"/store/{eid}", cookies=owner_cookies) assert r.status_code == 404 def test_marketplace_listing_includes_owner_quarantined(self, web_client): """Submitter sees their own non-approved entries in the /api/marketplace/items?tab=flea grid; non-owner does not.""" owner_id, owner_cookies = _create_user(web_client, "owner@x.com") eid, _sub_id = _seed_quarantined_entity(owner_id, "owner@x.com", "c4") # Owner — their own quarantined card surfaces with is_viewer_owner=True. r = web_client.get("/api/marketplace/items?tab=flea", cookies=owner_cookies) assert r.status_code == 200 items = r.json()["items"] own = [it for it in items if it["id"] == f"flea-{eid}"] assert own, f"owner should see own quarantined item; got {[it['id'] for it in items]}" assert own[0]["visibility_status"] != "approved" assert own[0]["is_viewer_owner"] is True # Non-owner — same listing must NOT surface the quarantined entry. _, snoop_cookies = _create_user(web_client, "snoop@x.com") r = web_client.get("/api/marketplace/items?tab=flea", cookies=snoop_cookies) assert r.status_code == 200 items = r.json()["items"] snoop = [it for it in items if it["id"] == f"flea-{eid}"] assert not snoop, "non-owner must not see another user's quarantined item" # --------------------------------------------------------------------------- # v35 archive (soft delete) semantics # --------------------------------------------------------------------------- class TestArchiveSoftDelete: def _upload_clean(self, web_client, cookies, name="clean1"): """Helper: upload a clean skill that lands as approved (no API key in test env -> auto-approve via guardrails-disabled fallback).""" c = web_client.post( "/api/store/entities", files={"file": ("s.zip", _make_skill_zip(name), "application/zip")}, data={ "type": "skill", "description": ( "Use when verifying lifecycle and admin flows over a " "clean reference bundle that passes every guardrail tier" ), }, cookies=cookies, ) assert c.status_code == 201, c.text return c.json()["id"] def test_archive_frees_name_for_reupload(self, web_client): """v36 rename-on-archive: owner uploads `myskill`, archives, re-uploads `myskill` → 201. Both rows exist; archived row's name carries the `__archived__` marker.""" from src.repositories.store_entities import StoreEntitiesRepository from src.store_naming import is_archived_name _, owner_cookies = _create_user(web_client, "rename-archive@x.com") eid_v1 = self._upload_clean(web_client, owner_cookies, name="myskill") # Archive v1. r = web_client.delete( f"/api/store/entities/{eid_v1}", cookies=owner_cookies, ) assert r.status_code == 204, r.text # Re-upload under the original name — must succeed. eid_v2 = self._upload_clean(web_client, owner_cookies, name="myskill") assert eid_v2 != eid_v1, "re-upload should produce a new entity id" conn = get_system_db() repo = StoreEntitiesRepository(conn) v1 = repo.get(eid_v1) v2 = repo.get(eid_v2) conn.close() assert v1["visibility_status"] == "archived" assert is_archived_name(v1["name"]), ( f"archived row name must carry the rename suffix; got {v1['name']!r}" ) assert v2["visibility_status"] in ("approved", "pending") assert v2["name"] == "myskill", ( f"new row should keep plain name; got {v2['name']!r}" ) def test_archive_renames_baked_skill_dir_on_disk(self, web_client): """The on-disk `skills//` directory is renamed to `skills//` and SKILL.md frontmatter is rewritten in lockstep so consumers' Claude Code resolves the new slug.""" from app.utils import get_store_dir from src.repositories.store_entities import StoreEntitiesRepository from src.store_naming import suffixed_name owner_id, owner_cookies = _create_user(web_client, "disk-rename@x.com") eid = self._upload_clean(web_client, owner_cookies, name="diskskill") old_suffix = suffixed_name("diskskill", "disk-rename") old_dir = Path(get_store_dir()) / eid / "plugin" / "skills" / old_suffix assert old_dir.is_dir(), f"pre-archive: missing {old_dir}" web_client.delete( f"/api/store/entities/{eid}", cookies=owner_cookies, ) conn = get_system_db() new_name = StoreEntitiesRepository(conn).get(eid)["name"] conn.close() new_suffix = suffixed_name(new_name, "disk-rename") new_dir = Path(get_store_dir()) / eid / "plugin" / "skills" / new_suffix assert new_dir.is_dir(), f"post-archive: missing {new_dir}" assert not old_dir.exists(), ( f"old slug dir must be gone post-archive; still found {old_dir}" ) # Frontmatter rewritten to new suffix. skill_md = (new_dir / "SKILL.md").read_text(encoding="utf-8") assert f"name: {new_suffix}" in skill_md, ( f"SKILL.md frontmatter not updated; got:\n{skill_md[:200]}" ) def test_un_archive_strips_suffix_back_to_original(self, web_client): """Admin un-archive (set_visibility('approved') from 'archived') strips the `__archived__\\d+$` suffix and restores the original name + clears archive metadata.""" from src.repositories.store_entities import StoreEntitiesRepository from src.store_naming import is_archived_name owner_id, owner_cookies = _create_user(web_client, "unarch@x.com") eid = self._upload_clean(web_client, owner_cookies, name="back") web_client.delete(f"/api/store/entities/{eid}", cookies=owner_cookies) conn = get_system_db() repo = StoreEntitiesRepository(conn) assert is_archived_name(repo.get(eid)["name"]) repo.set_visibility(eid, "approved") row = repo.get(eid) conn.close() assert row["name"] == "back" assert row["visibility_status"] == "approved" assert row["archived_at"] is None assert row["archived_by"] is None def test_un_archive_when_name_taken_appends_restored_suffix(self, web_client): """Owner archives `taken`, re-uploads `taken` (new entity), admin un-archives the original. Original entity gets `taken-restored-1` since the slot is occupied.""" from src.repositories.store_entities import StoreEntitiesRepository owner_id, owner_cookies = _create_user(web_client, "conflict-arch@x.com") eid_v1 = self._upload_clean(web_client, owner_cookies, name="taken") web_client.delete(f"/api/store/entities/{eid_v1}", cookies=owner_cookies) # Re-upload takes the slot. self._upload_clean(web_client, owner_cookies, name="taken") conn = get_system_db() repo = StoreEntitiesRepository(conn) repo.set_visibility(eid_v1, "approved") row = repo.get(eid_v1) conn.close() assert row["name"] == "taken-restored-1", ( f"un-archive into taken slot must append -restored-1; " f"got {row['name']!r}" ) def test_active_same_name_still_409(self, web_client): """Regression: when the prior entity is NOT archived, the same-name 409 still fires.""" _, owner_cookies = _create_user(web_client, "still409@x.com") self._upload_clean(web_client, owner_cookies, name="active") c = web_client.post( "/api/store/entities", files={"file": ("s.zip", _make_skill_zip("active"), "application/zip")}, data={"type": "skill"}, cookies=owner_cookies, ) assert c.status_code == 409, c.text assert c.json()["detail"] == "conflict_owner_name" def test_admin_queue_strips_archive_suffix_for_display(self, web_client): """Admin queue renders the original name (not the suffixed one) in the row's name cell so admins don't see ugly markers.""" owner_id, owner_cookies = _create_user(web_client, "displaystrip@x.com") eid = self._upload_clean(web_client, owner_cookies, name="displaytest") web_client.delete(f"/api/store/entities/{eid}", cookies=owner_cookies) _, admin_cookies = _create_admin(web_client) r = web_client.get( "/admin/store/submissions?status=archived", cookies=admin_cookies, ) assert r.status_code == 200 body = r.text assert "displaytest" in body assert "__archived__" not in body, ( "admin queue must strip the archive-rename suffix for display" ) def test_owner_can_archive_approved_entity(self, web_client): """DELETE without ?hard=true on owner's approved entity = soft archive. Bundle stays on disk; existing installs preserved.""" from src.repositories.store_entities import StoreEntitiesRepository _, owner_cookies = _create_user(web_client, "owner@x.com") eid = self._upload_clean(web_client, owner_cookies, name="arch-1") # Pre-archive sanity. conn = get_system_db() ent = StoreEntitiesRepository(conn).get(eid) assert ent["visibility_status"] == "approved" conn.close() r = web_client.delete(f"/api/store/entities/{eid}", cookies=owner_cookies) assert r.status_code == 204, r.text conn = get_system_db() ent = StoreEntitiesRepository(conn).get(eid) assert ent is not None # row preserved assert ent["visibility_status"] == "archived" assert ent["archived_at"] is not None assert ent["archived_by"] == "owner" # Bundle dir still on disk. from app.utils import get_store_dir assert (get_store_dir() / eid).exists() conn.close() def test_owner_hard_delete_refused(self, web_client): """Owner cannot pass ?hard=true — admin-only path.""" _, owner_cookies = _create_user(web_client, "owner@x.com") eid = self._upload_clean(web_client, owner_cookies, name="arch-2") r = web_client.delete(f"/api/store/entities/{eid}?hard=true", cookies=owner_cookies) assert r.status_code == 403 assert r.json()["detail"]["code"] == "hard_delete_admin_only" def test_admin_can_hard_delete(self, web_client): from src.repositories.store_entities import StoreEntitiesRepository from app.utils import get_store_dir _, owner_cookies = _create_user(web_client, "owner@x.com") eid = self._upload_clean(web_client, owner_cookies, name="arch-3") bundle_dir = get_store_dir() / eid assert bundle_dir.exists() _, admin_cookies = _create_admin(web_client) r = web_client.delete(f"/api/store/entities/{eid}?hard=true", cookies=admin_cookies) assert r.status_code == 204, r.text conn = get_system_db() assert StoreEntitiesRepository(conn).get(eid) is None conn.close() assert not bundle_dir.exists() def test_archived_excluded_from_marketplace_listing(self, web_client): """Approved → archived: every browse listing hides it (even owner).""" _, owner_cookies = _create_user(web_client, "owner@x.com") eid = self._upload_clean(web_client, owner_cookies, name="arch-4") web_client.delete(f"/api/store/entities/{eid}", cookies=owner_cookies) # Owner — own archived must NOT surface in the public-style listing. r = web_client.get("/api/marketplace/items?tab=flea", cookies=owner_cookies) assert r.status_code == 200 ids = {it["id"] for it in r.json()["items"]} assert f"flea-{eid}" not in ids def test_install_refused_on_archived(self, web_client): """Archived entities can't be added to my stack.""" _, owner_cookies = _create_user(web_client, "owner@x.com") eid = self._upload_clean(web_client, owner_cookies, name="arch-5") web_client.delete(f"/api/store/entities/{eid}", cookies=owner_cookies) _, other_cookies = _create_user(web_client, "other@x.com") r = web_client.post( f"/api/store/entities/{eid}/install", cookies=other_cookies, ) assert r.status_code == 409 assert r.json()["detail"] == "entity_not_approved" def test_archived_still_serves_existing_installs(self, web_client): """Pre-existing user_store_installs keep getting the entity in list_for_user even after archive (drives marketplace.zip serve).""" from src.repositories.store_entities import StoreEntitiesRepository from src.repositories.user_store_installs import UserStoreInstallsRepository _, owner_cookies = _create_user(web_client, "owner@x.com") eid = self._upload_clean(web_client, owner_cookies, name="arch-6") # Other user installs while approved. other_id, other_cookies = _create_user(web_client, "other@x.com") web_client.post(f"/api/store/entities/{eid}/install", cookies=other_cookies) # Owner archives. web_client.delete(f"/api/store/entities/{eid}", cookies=owner_cookies) # Other user's stack still has it. conn = get_system_db() installs = UserStoreInstallsRepository(conn).list_for_user(other_id) ids = {r["id"] for r in installs} assert eid in ids, "archived entity must still serve to existing installs" # And carries archived flag for the badge. row = next(r for r in installs if r["id"] == eid) assert row["visibility_status"] == "archived" conn.close() def test_owner_cannot_archive_quarantined(self, web_client): """Owner Delete on quarantined still refused (existing v32 policy).""" user_id, user_cookies = _create_user(web_client, "u@x.com") eid, _sub_id = _seed_quarantined_entity(user_id, "u@x.com", "q-arch") r = web_client.delete(f"/api/store/entities/{eid}", cookies=user_cookies) assert r.status_code == 403 assert r.json()["detail"]["code"] == "quarantined_owner_cannot_delete" def test_admin_can_archive_quarantined(self, web_client): """Admin can archive a quarantined entity (separate from override + hard-delete paths — admin keeps full control).""" from src.repositories.store_entities import StoreEntitiesRepository user_id, _ = _create_user(web_client, "u@x.com") eid, _sub_id = _seed_quarantined_entity(user_id, "u@x.com", "q-arch2") _, admin_cookies = _create_admin(web_client) r = web_client.delete(f"/api/store/entities/{eid}", cookies=admin_cookies) assert r.status_code == 204, r.text conn = get_system_db() ent = StoreEntitiesRepository(conn).get(eid) assert ent["visibility_status"] == "archived" conn.close() def test_owners_endpoint_filters_quarantined_for_non_admin(self, web_client): """A user with only quarantined uploads must NOT appear in the public /api/store/owners dropdown.""" user_id, _ = _create_user(web_client, "spammer@x.com") _seed_quarantined_entity(user_id, "spammer@x.com", "only-bad") _, other_cookies = _create_user(web_client, "other@x.com") r = web_client.get("/api/store/owners", cookies=other_cookies) assert r.status_code == 200 owner_ids = {o["user_id"] for o in r.json()} assert "spammer" not in owner_ids def test_categories_endpoint_filters_quarantined_for_non_owner(self, web_client): """`/api/marketplace/categories?tab=flea` aggregates per-category counts. The visibility predicate is duplicated inline in marketplace.py (drift risk against repo); this test locks the parity with marketplace items so a future change to the repo clause that misses the inline copy gets caught.""" owner_id, owner_cookies = _create_user(web_client, "qcat-owner@x.com") _seed_quarantined_entity(owner_id, "qcat-owner@x.com", "qcat") _, snoop_cookies = _create_user(web_client, "qcat-snoop@x.com") r = web_client.get( "/api/marketplace/categories?tab=flea", cookies=snoop_cookies, ) assert r.status_code == 200, r.text body = r.json() total = sum(c.get("count", 0) for c in body.get("items", [])) assert total == 0, ( "non-owner saw quarantined entry counted in /categories: " f"{body}" ) r = web_client.get( "/api/marketplace/categories?tab=flea", cookies=owner_cookies, ) body = r.json() owner_total = sum(c.get("count", 0) for c in body.get("items", [])) assert owner_total >= 1, ( "owner should count own quarantined entry in /categories: " f"{body}" ) # --------------------------------------------------------------------------- # v35 lifecycle marking on submissions (archived / deleted) # --------------------------------------------------------------------------- class TestSubmissionLifecycleMarking: def _upload_clean(self, web_client, cookies, name="lc"): c = web_client.post( "/api/store/entities", files={"file": ("s.zip", _make_skill_zip(name), "application/zip")}, data={"type": "skill"}, cookies=cookies, ) assert c.status_code == 201, c.text return c.json()["id"] def test_archive_surfaces_via_entity_visibility_join(self, web_client): """Verdict on submission stays immutable; archived chip surfaces the row because store_entities.visibility_status flipped to 'archived'. Locks in the JOIN-based architecture replacing the prior denormalization.""" from src.repositories.store_submissions import StoreSubmissionsRepository _, owner_cookies = _create_user(web_client, "owner@x.com") eid = self._upload_clean(web_client, owner_cookies, name="lc-arch") conn = get_system_db() sub = StoreSubmissionsRepository(conn).latest_for_entity(eid) sid = sub["id"] assert sub["status"] == "approved" conn.close() web_client.delete(f"/api/store/entities/{eid}", cookies=owner_cookies) # Verdict UNCHANGED — that's the whole point: submission.status is the # forensic record of what was decided at review time, not lifecycle. conn = get_system_db() sub = StoreSubmissionsRepository(conn).get(sid) assert sub["status"] == "approved" conn.close() # But the JOIN-based archived chip surfaces it because the entity's # visibility_status flipped. _, admin_cookies = _create_admin(web_client) r = web_client.get( "/api/admin/store/submissions?status=archived", cookies=admin_cookies, ) assert r.status_code == 200 names = {s["name"] for s in r.json()["items"]} assert "lc-arch" in names def test_archived_chip_surfaces_entity_archived_outside_delete_flow(self, web_client): """Regression for the user-reported bug: archived entity didn't show up in ?status=archived because the prior denormalized field never flipped. Pre-seed a submission with status='approved' and manually flip the linked entity to visibility_status='archived' (simulating any code path that bypasses the soft-delete API), then assert the archived chip surfaces it.""" from src.repositories.store_entities import StoreEntitiesRepository _, owner_cookies = _create_user(web_client, "owner@x.com") eid = self._upload_clean(web_client, owner_cookies, name="lc-bypass-archive") # Bypass the API: flip visibility directly at the repo layer. conn = get_system_db() StoreEntitiesRepository(conn).set_visibility(eid, "archived") conn.close() _, admin_cookies = _create_admin(web_client) r = web_client.get( "/api/admin/store/submissions?status=archived", cookies=admin_cookies, ) assert r.status_code == 200, r.text names = {s["name"] for s in r.json()["items"]} assert "lc-bypass-archive" in names # And the default queue excludes it. r = web_client.get("/api/admin/store/submissions", cookies=admin_cookies) names = {s["name"] for s in r.json()["items"]} assert "lc-bypass-archive" not in names def test_hard_delete_marks_submissions_deleted(self, web_client): from src.repositories.store_submissions import StoreSubmissionsRepository _, owner_cookies = _create_user(web_client, "owner@x.com") eid = self._upload_clean(web_client, owner_cookies, name="lc-del") conn = get_system_db() sub_id = StoreSubmissionsRepository(conn).latest_for_entity(eid)["id"] conn.close() _, admin_cookies = _create_admin(web_client) r = web_client.delete(f"/api/store/entities/{eid}?hard=true", cookies=admin_cookies) assert r.status_code == 204, r.text conn = get_system_db() sub = StoreSubmissionsRepository(conn).get(sub_id) assert sub["status"] == "deleted" # entity_id is preserved as a tombstone — the live entity row # is gone, but keeping the pointer lets the detail page # resolve the activity timeline by querying audit_log for # `store_entity:{entity_id}` even after the row is dropped. assert sub["entity_id"] == eid conn.close() def test_default_listing_excludes_archived_and_deleted(self, web_client): """Admin queue defaults to actionable rows. Archived + deleted only surface when the user clicks the dedicated chip.""" from src.repositories.store_submissions import StoreSubmissionsRepository _, owner_cookies = _create_user(web_client, "owner@x.com") eid_arch = self._upload_clean(web_client, owner_cookies, name="lc-d-arch") eid_keep = self._upload_clean(web_client, owner_cookies, name="lc-d-keep") web_client.delete(f"/api/store/entities/{eid_arch}", cookies=owner_cookies) _, admin_cookies = _create_admin(web_client) r = web_client.get("/api/admin/store/submissions", cookies=admin_cookies) assert r.status_code == 200 names = {s["name"] for s in r.json()["items"]} assert "lc-d-keep" in names assert "lc-d-arch" not in names # Explicit chip surfaces it. r = web_client.get( "/api/admin/store/submissions?status=archived", cookies=admin_cookies, ) names = {s["name"] for s in r.json()["items"]} assert names == {"lc-d-arch"} def test_deleted_chip_surfaces_hard_deleted(self, web_client): _, owner_cookies = _create_user(web_client, "owner@x.com") eid = self._upload_clean(web_client, owner_cookies, name="lc-d-hard") _, admin_cookies = _create_admin(web_client) web_client.delete(f"/api/store/entities/{eid}?hard=true", cookies=admin_cookies) r = web_client.get( "/api/admin/store/submissions?status=deleted", cookies=admin_cookies, ) names = {s["name"] for s in r.json()["items"]} assert "lc-d-hard" in names def test_deleted_submission_detail_renders_timeline(self, web_client): """Regression: hard-deleted submissions used to lose their activity timeline because mark_deleted_for_entity nulled entity_id, severing the audit_log linkage. Tombstone semantics: entity_id is preserved post-delete so `store_entity:{entity_id}` audits keep resolving.""" from src.repositories.store_submissions import StoreSubmissionsRepository _, owner_cookies = _create_user(web_client, "owner@x.com") eid = self._upload_clean(web_client, owner_cookies, name="lc-d-timeline") conn = get_system_db() sub_id = StoreSubmissionsRepository(conn).latest_for_entity(eid)["id"] conn.close() _, admin_cookies = _create_admin(web_client) r = web_client.delete(f"/api/store/entities/{eid}?hard=true", cookies=admin_cookies) assert r.status_code == 204, r.text # Detail page must render and include at least one audit row # (creation events scoped to store_entity:{eid} would otherwise # be invisible after the entity_id link was nulled). r = web_client.get( f"/admin/store/submissions/{sub_id}", cookies=admin_cookies, ) assert r.status_code == 200 # Sanity: the deleted submission's body references the original # entity_id (the tombstone), proving the linkage survives. assert eid in r.text # --------------------------------------------------------------------------- # Coverage gap fills (post-pre-PR audit) # --------------------------------------------------------------------------- class TestFleaDetailSubmissionStatusField: """The quarantine banner's auto-refresh JS polls `/api/marketplace/flea/{id}/detail` and reloads when `submission_status` flips off the pending verdicts. Visibility alone is insufficient because `blocked_llm` keeps the entity at `visibility_status='pending'`. This class locks in the contract that the field is populated for owner/admin only.""" def _upload_clean(self, web_client, cookies, name="dst"): c = web_client.post( "/api/store/entities", files={"file": ("s.zip", _make_skill_zip(name), "application/zip")}, data={"type": "skill"}, cookies=cookies, ) assert c.status_code == 201, c.text return c.json()["id"] def test_owner_sees_submission_status(self, web_client): _, owner_cookies = _create_user(web_client, "owner@x.com") eid = self._upload_clean(web_client, owner_cookies, name="dst-own") r = web_client.get( f"/api/marketplace/flea/{eid}/detail", cookies=owner_cookies, ) assert r.status_code == 200 body = r.json() # Verdict landed by the time create returned 201 (clean upload skips # LLM since fixtures don't carry a real api key) — value is whatever # status the runner left; just assert the field is populated. assert body.get("submission_status") is not None def test_admin_sees_submission_status_for_any_entity(self, web_client): _, owner_cookies = _create_user(web_client, "owner@x.com") eid = self._upload_clean(web_client, owner_cookies, name="dst-adm") _, admin_cookies = _create_admin(web_client) r = web_client.get( f"/api/marketplace/flea/{eid}/detail", cookies=admin_cookies, ) assert r.status_code == 200 assert r.json().get("submission_status") is not None def test_other_user_does_not_see_submission_status(self, web_client): _, owner_cookies = _create_user(web_client, "owner@x.com") eid = self._upload_clean(web_client, owner_cookies, name="dst-other") # Non-owner non-admin can hit the endpoint when entity is approved # (404 otherwise per the visibility gate). Field must stay null. _, viewer_cookies = _create_user(web_client, "viewer@x.com") r = web_client.get( f"/api/marketplace/flea/{eid}/detail", cookies=viewer_cookies, ) if r.status_code == 200: assert r.json().get("submission_status") is None # If the entity isn't approved (404 for non-owner non-admin), # the leak isn't reachable either way — also acceptable. class TestDetailPageEntityLifecycleRow: """The submission detail page renders Status (verdict) and Entity lifecycle side by side so admins see the verdict-vs-lifecycle distinction at a glance. Locks in the row's presence so a future template refactor can't silently drop it.""" def _upload_clean(self, web_client, cookies, name="elr"): c = web_client.post( "/api/store/entities", files={"file": ("s.zip", _make_skill_zip(name), "application/zip")}, data={"type": "skill"}, cookies=cookies, ) assert c.status_code == 201, c.text return c.json()["id"] def test_detail_renders_entity_lifecycle_row(self, web_client): from src.repositories.store_submissions import StoreSubmissionsRepository _, owner_cookies = _create_user(web_client, "owner@x.com") eid = self._upload_clean(web_client, owner_cookies, name="elr-1") conn = get_system_db() sub_id = StoreSubmissionsRepository(conn).latest_for_entity(eid)["id"] conn.close() _, admin_cookies = _create_admin(web_client) r = web_client.get( f"/admin/store/submissions/{sub_id}", cookies=admin_cookies, ) assert r.status_code == 200 # Both labels must coexist on the page. assert "Status (verdict)" in r.text assert "Entity lifecycle" in r.text class TestAuditLogResourcePrefix: """Locks in the contract that submission-event audits land at resources the activity-timeline query knows how to find. Two paths emit them: * `app/api/store.py:_audit` helper hardcodes `store_entity:` prefix — submission events written this way live at `store_entity:{sub_id}`. Timeline query covers it. * `src/store_guardrails/runner.py` uses `store_submission:{sub_id}` — the post-fix convention. Timeline query covers it. Either format must surface in the rendered detail page so admins can audit the lifecycle of any submission.""" def _upload_clean(self, web_client, cookies, name="aud"): c = web_client.post( "/api/store/entities", files={"file": ("s.zip", _make_skill_zip(name), "application/zip")}, data={"type": "skill"}, cookies=cookies, ) assert c.status_code == 201, c.text return c.json()["id"] def test_helper_emitted_audits_surface_in_timeline(self, web_client): """The `_audit` helper writes resource=`store_entity:{sub_id}` for submission events. Timeline query must include that pattern so `store.submission.accepted` (or `.approved`) rows are visible on the detail page.""" from src.repositories.store_submissions import StoreSubmissionsRepository _, owner_cookies = _create_user(web_client, "owner@x.com") eid = self._upload_clean(web_client, owner_cookies, name="aud-helper") conn = get_system_db() sub_id = StoreSubmissionsRepository(conn).latest_for_entity(eid)["id"] rows = conn.execute( """SELECT resource, action FROM audit_log WHERE resource = ? AND action LIKE 'store.submission.%'""", [f"store_entity:{sub_id}"], ).fetchall() conn.close() assert rows, ( "expected at least one store.submission.* audit row at " "resource=store_entity:{sub_id} — the helper format the " "timeline query relies on" ) # And the rendered timeline must include the action. _, admin_cookies = _create_admin(web_client) r = web_client.get( f"/admin/store/submissions/{sub_id}", cookies=admin_cookies, ) assert r.status_code == 200 # Either accepted (guardrails on) or approved (guardrails off); # both appear in rows returned by the timeline query when the # query covers the helper's `store_entity:` resource pattern. assert any(a in r.text for a in ( "store.submission.accepted", "store.submission.approved", )) def test_runner_audit_uses_prefixed_resource(self, monkeypatch, web_client): """runner.py's audit calls must use `store_submission:{id}` — we drive that path by faking a config-loader failure inside run_llm_review so the runner emits a `review_error` audit synchronously.""" from src.repositories.store_submissions import StoreSubmissionsRepository from src.store_guardrails import runner as runner_mod _, owner_cookies = _create_user(web_client, "owner@x.com") eid = self._upload_clean(web_client, owner_cookies, name="aud-runner") conn = get_system_db() sub_id = StoreSubmissionsRepository(conn).latest_for_entity(eid)["id"] plugin_dir = Path(get_store_dir()) / eid / "plugin" conn.close() def boom() -> str: raise RuntimeError("missing api key for test") runner_mod.run_llm_review( submission_id=sub_id, plugin_dir=plugin_dir, conn_factory=get_system_db, api_key_loader=boom, model_loader=lambda: "claude-haiku-4-5-20251001", ) conn = get_system_db() rows = conn.execute( """SELECT resource, action FROM audit_log WHERE resource = ? AND action = 'store.submission.review_error'""", [f"store_submission:{sub_id}"], ).fetchall() conn.close() assert rows, ( "runner.py must emit prefixed store_submission:{id} so the " "timeline query resolves it — bare-id format is legacy only" )