test(store): full flea-market lifecycle integration coverage (#334)

Integration test class covering the v1 → subscribe → v2 → v3-blocked →
override → restore-v1 lifecycle from issuer + admin + subscribed-user
perspectives. Asserts BOTH the entity state AND the served
`marketplace.zip` bytes + ETag at each transition — the channel the
analyst's `agnes pull` actually hits.

Main test:
- TestFullLifecycleFromInstaller::test_main_lifecycle_v1_v2_v3blocked_override_restorev1

5 corner cases covering role × phase gaps surfaced by think-hard
coverage analysis:
- test_unsubscribed_user_does_not_get_entity — negative control on
  install filter
- test_late_subscriber_during_quarantine_gets_v2 — fresh install
  during v3-blocked window must get v2
- test_non_owner_does_not_see_quarantine_banner — privacy gate: only
  owner + admin see in-flight failure detail
- test_second_restore_of_v1_triggers_reuse_path — PR #332 lifecycle
  validation (the EXACT live agnes-development bug)
- test_archived_entity_keeps_serving_installed_subscribers —
  CLAUDE.md contract: archive hides from browse but preserves
  existing user_store_installs

Reuses existing scaffolding (`_create_user`, `_make_skill_zip`,
conftest autouse). Adds class-scoped static helpers `_install`,
`_serve_zip`, `_drive_llm`, `_latest_sub_id`, `_ent`, `_installs`,
`_setup_guardrails_on`.

Plan: ~/.claude/plans/peppy-napping-rose.md.

198 tests green across touched surface (admin + versions + api +
marketplace).
This commit is contained in:
Vojtech 2026-05-16 14:41:09 +04:00 committed by GitHub
parent cd03028776
commit bc5956ac94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 535 additions and 0 deletions

View file

@ -10,6 +10,19 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased]
### Internal
- Added `TestFullLifecycleFromInstaller` integration test class
(`tests/test_store_entity_versions.py`) covering the full
flea-market lifecycle from issuer / admin / subscribed-user
perspectives. Main test walks v1 upload → installer subscribes →
v2 promote → v3 blocked → admin force-overrides → restore v1,
asserting BOTH entity state AND served `marketplace.zip` bytes +
ETag at each transition. Plus 5 corner cases:
unsubscribed-user negative control, late-subscriber-during-
quarantine, non-owner privacy gate, second-restore reuse path
(PR #332 lifecycle validation), and archived-entity-keeps-
serving-installs (CLAUDE.md contract).
## [0.54.24] — 2026-05-16
### Fixed

View file

@ -2210,3 +2210,525 @@ class TestRestoreReusesApprovedVerdict:
assert (v3_sub["llm_findings"] or {}).get(
"reused_from_submission_id"
) is None
# Mock LLM payloads used by the lifecycle integration test class below.
_MOCK_APPROVE_LIFECYCLE = {
"risk_level": "safe", "summary": "ok",
"findings": [], "template_placeholders_found": 0,
"reviewed_by_model": "mock-haiku", "error": None,
"content_quality": {"verdict": "pass", "issues": []},
}
_MOCK_BLOCK_LIFECYCLE = {
"risk_level": "high", "summary": "mock block — security issue",
"findings": [{"severity": "high", "category": "exfiltration",
"file": "run.sh", "explanation": "mock-block"}],
"template_placeholders_found": 0,
"reviewed_by_model": "mock-haiku", "error": None,
"content_quality": {"verdict": "pass", "issues": []},
}
class TestFullLifecycleFromInstaller:
"""Integration test for the full flea-market lifecycle from
issuer, admin, and subscribed-user perspectives.
Walks v1 upload installer subscribes v2 promote v3 blocked
admin force-overrides restore v1. Asserts BOTH entity state
AND served `marketplace.zip` bytes + ETag at each transition.
Plan: ~/.claude/plans/peppy-napping-rose.md.
"""
@staticmethod
def _install(client, eid, cookies):
r = client.post(f"/api/store/entities/{eid}/install", cookies=cookies)
assert r.status_code == 200, r.text
@staticmethod
def _serve_zip(client, cookies, if_none_match=None):
import io as _io
import zipfile as _zip
headers = {}
if if_none_match:
headers["If-None-Match"] = f'"{if_none_match}"'
r = client.get("/marketplace.zip", cookies=cookies, headers=headers)
etag = r.headers.get("etag", "").strip('"')
if r.status_code == 304:
return etag, None, 304
assert r.status_code == 200, r.text
with _zip.ZipFile(_io.BytesIO(r.content)) as zf:
contents = {n: zf.read(n) for n in zf.namelist()}
return etag, contents, 200
@staticmethod
def _drive_llm(monkeypatch, eid, sub_id, version_no, mock):
from pathlib import Path
from app.utils import get_store_dir
from src.store_guardrails.runner import run_llm_review
monkeypatch.setattr(
"src.store_guardrails.llm_review.review_bundle",
lambda *a, **kw: mock,
)
run_llm_review(
sub_id,
plugin_dir=Path(get_store_dir()) / eid / "versions" / f"v{version_no}" / "plugin",
conn_factory=get_system_db,
api_key_loader=lambda: "sk",
model_loader=lambda: "mock-haiku",
)
@staticmethod
def _latest_sub_id(eid):
from src.repositories.store_submissions import StoreSubmissionsRepository
conn = get_system_db()
sid = StoreSubmissionsRepository(conn).latest_for_entity(eid)["id"]
conn.close()
return sid
@staticmethod
def _ent(eid):
conn = get_system_db()
e = StoreEntitiesRepository(conn).get(eid)
conn.close()
return e
@staticmethod
def _installs(installer_id):
from src.repositories.user_store_installs import UserStoreInstallsRepository
conn = get_system_db()
installs = UserStoreInstallsRepository(conn).list_for_user(installer_id)
conn.close()
return installs
@staticmethod
def _setup_guardrails_on(monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-fake-lifecycle")
monkeypatch.setattr(
"app.api.store.get_guardrails_enabled", lambda: True,
)
monkeypatch.setattr(
"app.api.store.get_guardrails_llm_provider_ready", lambda: True,
)
monkeypatch.setattr(
"app.api.store.get_guardrails_review_model",
lambda: "mock-haiku",
)
monkeypatch.setattr(
"app.api.store._schedule_llm_review", lambda *a, **kw: None,
)
def test_main_lifecycle_v1_v2_v3blocked_override_restorev1(
self, web_client, monkeypatch,
):
"""User's exact spec, end-to-end."""
self._setup_guardrails_on(monkeypatch)
owner_id, owner_cookies = _create_user(web_client, "lc-owner@x.com")
installer_id, installer_cookies = _create_user(web_client, "lc-installer@x.com")
_, admin_cookies = _create_admin(web_client, "lc-admin@x.com")
# ── Phase 1 ── v1 upload (clean, mock approve)
v1_zip = _make_skill_zip("lifecycle", body="V1 body content explaining when to use this skill in detail. " * 6)
r = web_client.post(
"/api/store/entities",
files={"file": ("v1.zip", v1_zip, "application/zip")},
data={"type": "skill", "description": _OK_DESC},
cookies=owner_cookies,
)
assert r.status_code == 201, r.text
eid = r.json()["id"]
v1_sub_id = self._latest_sub_id(eid)
self._drive_llm(monkeypatch, eid, v1_sub_id, 1, _MOCK_APPROVE_LIFECYCLE)
ent = self._ent(eid)
assert ent["visibility_status"] == "approved"
assert ent["version_no"] == 1
v1_hash = ent["version"]
# ── Phase 2 ── installer subscribes
self._install(web_client, eid, installer_cookies)
installs = self._installs(installer_id)
assert {r["id"] for r in installs} == {eid}
assert installs[0]["version"] == v1_hash
etag_v1, contents_v1, _ = self._serve_zip(web_client, installer_cookies)
# Skills/agents land bundled under plugins/store-bundle/<skill_slug>/.
# The suffixed name `lifecycle-by-lc-owner` identifies our entity.
skill_files_v1 = [n for n in contents_v1 if "lifecycle-by-lc-owner" in n]
assert skill_files_v1, (
f"v1 bytes missing from marketplace.zip; got {list(contents_v1)[:5]}"
)
# ── Phase 3 ── v2 PUT (approve) → promote
v2_zip = _make_skill_zip("lifecycle", body="V2 body upgraded with more detail for the skill body content. " * 6)
r = web_client.put(
f"/api/store/entities/{eid}",
files={"file": ("v2.zip", v2_zip, "application/zip")},
cookies=owner_cookies,
)
assert r.status_code == 200, r.text
v2_sub_id = self._latest_sub_id(eid)
self._drive_llm(monkeypatch, eid, v2_sub_id, 2, _MOCK_APPROVE_LIFECYCLE)
ent = self._ent(eid)
assert ent["version_no"] == 2
v2_hash = ent["version"]
assert v2_hash != v1_hash
installs = self._installs(installer_id)
assert installs[0]["version"] == v2_hash
etag_v2, contents_v2, _ = self._serve_zip(web_client, installer_cookies)
assert etag_v2 != etag_v1, "etag must flip on v2 promote"
# ── Phase 4 ── v3 PUT (mock block) → stays at v2
v3_zip = _make_skill_zip("lifecycle", body="V3 body risky content that the LLM mock will mark as exfiltration. " * 6)
r = web_client.put(
f"/api/store/entities/{eid}",
files={"file": ("v3.zip", v3_zip, "application/zip")},
cookies=owner_cookies,
)
assert r.status_code == 200, r.text
v3_sub_id = self._latest_sub_id(eid)
self._drive_llm(monkeypatch, eid, v3_sub_id, 3, _MOCK_BLOCK_LIFECYCLE)
from src.repositories.store_submissions import StoreSubmissionsRepository
conn = get_system_db()
v3_sub = StoreSubmissionsRepository(conn).get(v3_sub_id)
ent = StoreEntitiesRepository(conn).get(eid)
conn.close()
assert v3_sub["status"] == "blocked_llm"
assert ent["version_no"] == 2
assert ent["version"] == v2_hash
installs = self._installs(installer_id)
assert installs[0]["version"] == v2_hash
etag_v3_check, _, _ = self._serve_zip(web_client, installer_cookies)
assert etag_v3_check == etag_v2, (
"etag must NOT flip on blocked submission"
)
# ── Phase 5 ── admin overrides v3 → promotes
r = web_client.post(
f"/api/admin/store/submissions/{v3_sub_id}/override",
json={"reason": "false positive cleared offline by admin team"},
cookies=admin_cookies,
)
assert r.status_code == 200, r.text
ent = self._ent(eid)
assert ent["version_no"] == 3
v3_hash = ent["version"]
assert v3_hash != v2_hash
installs = self._installs(installer_id)
assert installs[0]["version"] == v3_hash
etag_override, _, _ = self._serve_zip(web_client, installer_cookies)
assert etag_override != etag_v2
# ── Phase 6 ── restore v1 → v4 with v1 bytes
r = web_client.post(
f"/api/store/entities/{eid}/versions/1/restore",
cookies=owner_cookies,
)
assert r.status_code == 200, r.text
v4_sub_id = self._latest_sub_id(eid)
conn = get_system_db()
v4_pre = StoreSubmissionsRepository(conn).get(v4_sub_id)
conn.close()
# PR #332 reuse path may fire if v1 was approved by same model.
# If not, drive LLM manually.
if v4_pre["status"] == "pending_llm":
self._drive_llm(monkeypatch, eid, v4_sub_id, 4, _MOCK_APPROVE_LIFECYCLE)
ent = self._ent(eid)
assert ent["version_no"] == 4
assert ent["version"] == v1_hash, (
"v4 bytes byte-identical to v1 — entity.version (hash) "
"should match v1's"
)
installs = self._installs(installer_id)
assert installs[0]["version"] == v1_hash, (
"installer should receive v1 bytes through v4 promotion"
)
etag_restore, contents_restore, _ = self._serve_zip(web_client, installer_cookies)
assert etag_restore != etag_override
v1_skill = {n: b for n, b in contents_v1.items() if "SKILL.md" in n and "lifecycle-by-lc-owner" in n}
rs_skill = {n: b for n, b in contents_restore.items() if "SKILL.md" in n and "lifecycle-by-lc-owner" in n}
assert v1_skill == rs_skill, (
"restored SKILL.md byte-equal v1's SKILL.md"
)
# ── Corner cases ────────────────────────────────────────────────
def test_unsubscribed_user_does_not_get_entity(
self, web_client, monkeypatch,
):
"""G1 negative control: a third user who never installs the
entity must NEVER see it in list_for_user regardless of
which phase the lifecycle is in."""
self._setup_guardrails_on(monkeypatch)
owner_id, owner_cookies = _create_user(web_client, "unsub-owner@x.com")
unsub_id, unsub_cookies = _create_user(web_client, "unsub-other@x.com")
v1_zip = _make_skill_zip("unsubme", body="Body content for unsubscribed-user negative test that's long enough to clear threshold. " * 4)
r = web_client.post(
"/api/store/entities",
files={"file": ("v1.zip", v1_zip, "application/zip")},
data={"type": "skill", "description": _OK_DESC},
cookies=owner_cookies,
)
assert r.status_code == 201, r.text
eid = r.json()["id"]
self._drive_llm(monkeypatch, eid, self._latest_sub_id(eid), 1, _MOCK_APPROVE_LIFECYCLE)
installs = self._installs(unsub_id)
assert eid not in {row["id"] for row in installs}, (
"non-subscribed user must not receive entity in list_for_user"
)
# marketplace.zip for the non-subscriber must NOT contain the bundle.
_, contents, _ = self._serve_zip(web_client, unsub_cookies)
skill_files = [n for n in contents if "unsubme-by-unsub-owner" in n]
assert not skill_files, (
f"non-subscriber's marketplace.zip leaked the bundle: {skill_files}"
)
def test_late_subscriber_during_quarantine_gets_v2(
self, web_client, monkeypatch,
):
"""G2: subscriber installs AFTER v3 is blocked but BEFORE
override. Must get v2 bytes (entity.version_no=2 at install
time)."""
self._setup_guardrails_on(monkeypatch)
owner_id, owner_cookies = _create_user(web_client, "lateinst-owner@x.com")
v1_zip = _make_skill_zip("lateinst", body="V1 body for late-subscriber test. " * 6)
r = web_client.post(
"/api/store/entities",
files={"file": ("v1.zip", v1_zip, "application/zip")},
data={"type": "skill", "description": _OK_DESC},
cookies=owner_cookies,
)
assert r.status_code == 201, r.text
eid = r.json()["id"]
self._drive_llm(monkeypatch, eid, self._latest_sub_id(eid), 1, _MOCK_APPROVE_LIFECYCLE)
v2_zip = _make_skill_zip("lateinst", body="V2 body content advanced for late-subscriber test. " * 5)
r = web_client.put(
f"/api/store/entities/{eid}",
files={"file": ("v2.zip", v2_zip, "application/zip")},
cookies=owner_cookies,
)
assert r.status_code == 200
self._drive_llm(monkeypatch, eid, self._latest_sub_id(eid), 2, _MOCK_APPROVE_LIFECYCLE)
v2_hash = self._ent(eid)["version"]
# PUT v3 + block.
v3_zip = _make_skill_zip("lateinst", body="V3 body content risky for late-subscriber test. " * 5)
r = web_client.put(
f"/api/store/entities/{eid}",
files={"file": ("v3.zip", v3_zip, "application/zip")},
cookies=owner_cookies,
)
assert r.status_code == 200
self._drive_llm(monkeypatch, eid, self._latest_sub_id(eid), 3, _MOCK_BLOCK_LIFECYCLE)
# Late subscriber installs DURING quarantine. Should get v2.
late_id, late_cookies = _create_user(web_client, "lateinst-late@x.com")
self._install(web_client, eid, late_cookies)
installs = self._installs(late_id)
assert installs[0]["version"] == v2_hash, (
f"late subscriber during quarantine must get v2 hash; "
f"got {installs[0]['version'][:8]}"
)
def test_non_owner_does_not_see_quarantine_banner(
self, web_client, monkeypatch,
):
"""G3 privacy gate: during v3-blocked phase, a non-owner
non-admin third user hits /marketplace/flea/<eid>. Banner is
owner+admin-only third user must see the public approved
view without 'Latest edit failed review' copy."""
self._setup_guardrails_on(monkeypatch)
owner_id, owner_cookies = _create_user(web_client, "priv-owner@x.com")
v1_zip = _make_skill_zip("priv", body="V1 body for privacy-gate test that's long enough. " * 5)
r = web_client.post(
"/api/store/entities",
files={"file": ("v1.zip", v1_zip, "application/zip")},
data={"type": "skill", "description": _OK_DESC},
cookies=owner_cookies,
)
assert r.status_code == 201, r.text
eid = r.json()["id"]
self._drive_llm(monkeypatch, eid, self._latest_sub_id(eid), 1, _MOCK_APPROVE_LIFECYCLE)
# PUT v2 + block.
v2_zip = _make_skill_zip("priv", body="V2 body content risky for privacy-gate test that's long. " * 5)
r = web_client.put(
f"/api/store/entities/{eid}",
files={"file": ("v2.zip", v2_zip, "application/zip")},
cookies=owner_cookies,
)
assert r.status_code == 200
self._drive_llm(monkeypatch, eid, self._latest_sub_id(eid), 2, _MOCK_BLOCK_LIFECYCLE)
# Third user (not owner, not admin) hits detail page.
_, third_cookies = _create_user(web_client, "priv-third@x.com")
r = web_client.get(f"/marketplace/flea/{eid}", cookies=third_cookies)
assert r.status_code == 200, r.text
body = r.text
assert "Latest edit failed review" not in body, (
"third user must NOT see the v2+-edit failure banner"
)
assert "blocked_llm" not in body, (
"third user must NOT see blocked-status detail"
)
# Sanity: owner DOES see the banner.
r_owner = web_client.get(f"/marketplace/flea/{eid}", cookies=owner_cookies)
assert r_owner.status_code == 200
assert "Latest edit failed review" in r_owner.text, (
"owner must see the banner for the same in-flight failure"
)
def test_second_restore_of_v1_triggers_reuse_path(
self, web_client, monkeypatch,
):
"""G4 (live agnes-development bug, PR #332 lifecycle
validation): owner restores v1 v4. Then restores v1 AGAIN
v5. The PR #332 reuse path must fire because v4 was
approved by same model. v5 submission must NOT make a new
LLM call AND must carry reused_from_submission_id marker."""
from src.repositories.store_submissions import StoreSubmissionsRepository
self._setup_guardrails_on(monkeypatch)
owner_id, owner_cookies = _create_user(web_client, "reuse2-owner@x.com")
v1_zip = _make_skill_zip("reuse2", body="V1 body content for second-restore reuse test. " * 5)
r = web_client.post(
"/api/store/entities",
files={"file": ("v1.zip", v1_zip, "application/zip")},
data={"type": "skill", "description": _OK_DESC},
cookies=owner_cookies,
)
assert r.status_code == 201, r.text
eid = r.json()["id"]
v1_sub_id = self._latest_sub_id(eid)
self._drive_llm(monkeypatch, eid, v1_sub_id, 1, _MOCK_APPROVE_LIFECYCLE)
# PUT v2 → approve → entity at v2 (so v1 is no longer current).
v2_zip = _make_skill_zip("reuse2", body="V2 body content different from v1 for restore test. " * 5)
r = web_client.put(
f"/api/store/entities/{eid}",
files={"file": ("v2.zip", v2_zip, "application/zip")},
cookies=owner_cookies,
)
assert r.status_code == 200
self._drive_llm(monkeypatch, eid, self._latest_sub_id(eid), 2, _MOCK_APPROVE_LIFECYCLE)
assert self._ent(eid)["version_no"] == 2
# First restore v1 → v3 with v1 bytes. Reuse may or may not
# fire depending on whether v1's reviewed_by_model matches.
# (It does: both were stamped 'mock-haiku' via our drive_llm.)
r = web_client.post(
f"/api/store/entities/{eid}/versions/1/restore",
cookies=owner_cookies,
)
assert r.status_code == 200, r.text
v3_sub_id = self._latest_sub_id(eid)
conn = get_system_db()
v3_sub = StoreSubmissionsRepository(conn).get(v3_sub_id)
conn.close()
if v3_sub["status"] == "pending_llm":
self._drive_llm(monkeypatch, eid, v3_sub_id, 3, _MOCK_APPROVE_LIFECYCLE)
# By now v3 is approved + promoted. version_no=3.
assert self._ent(eid)["version_no"] == 3
# Count LLM calls BEFORE second restore.
call_count = {"n": 0}
def counting_mock(*a, **kw):
call_count["n"] += 1
return _MOCK_APPROVE_LIFECYCLE
monkeypatch.setattr(
"src.store_guardrails.llm_review.review_bundle", counting_mock,
)
# Second restore v1 → v4 with v1 bytes. PR #332 reuse path
# MUST fire because v3 (just promoted, byte-identical to v1)
# OR v1 itself qualifies (same hash, approved, same model).
r = web_client.post(
f"/api/store/entities/{eid}/versions/1/restore",
cookies=owner_cookies,
)
assert r.status_code == 200, r.text
v4_sub_id = self._latest_sub_id(eid)
conn = get_system_db()
v4_sub = StoreSubmissionsRepository(conn).get(v4_sub_id)
conn.close()
assert v4_sub["status"] == "approved", (
f"v4 must be approved via reuse path; got status={v4_sub['status']}"
)
assert (v4_sub["llm_findings"] or {}).get("reused_from_submission_id"), (
"v4 must carry reused_from_submission_id marker (PR #332)"
)
assert call_count["n"] == 0, (
f"second restore must NOT call LLM; count={call_count['n']}"
)
def test_archived_entity_keeps_serving_installed_subscribers(
self, web_client, monkeypatch,
):
"""G5: owner soft-archives entity → already-subscribed users
STILL get bundle served (per CLAUDE.md contract). Browse
listing for a third user does NOT include the entity."""
from src.repositories.store_entities import StoreEntitiesRepository
self._setup_guardrails_on(monkeypatch)
owner_id, owner_cookies = _create_user(web_client, "arch-owner@x.com")
installer_id, installer_cookies = _create_user(web_client, "arch-installer@x.com")
v1_zip = _make_skill_zip("archme", body="V1 body content for archive behavior test that's long. " * 5)
r = web_client.post(
"/api/store/entities",
files={"file": ("v1.zip", v1_zip, "application/zip")},
data={"type": "skill", "description": _OK_DESC},
cookies=owner_cookies,
)
assert r.status_code == 201, r.text
eid = r.json()["id"]
self._drive_llm(monkeypatch, eid, self._latest_sub_id(eid), 1, _MOCK_APPROVE_LIFECYCLE)
v1_hash = self._ent(eid)["version"]
self._install(web_client, eid, installer_cookies)
installs = self._installs(installer_id)
assert installs[0]["version"] == v1_hash
# Owner soft-archives.
r = web_client.delete(
f"/api/store/entities/{eid}", cookies=owner_cookies,
)
assert r.status_code == 200, r.text
conn = get_system_db()
ent_after = StoreEntitiesRepository(conn).get(eid)
conn.close()
assert ent_after["visibility_status"] == "archived"
# Already-installed user STILL has the entity in list_for_user.
installs_after = self._installs(installer_id)
assert eid in {row["id"] for row in installs_after}, (
"soft-archive must NOT cascade to existing user_store_installs "
"(CLAUDE.md contract)"
)
# Third user browsing marketplace must NOT see the entity.
_, third_cookies = _create_user(web_client, "arch-third@x.com")
r = web_client.get("/api/store/entities", cookies=third_cookies)
assert r.status_code == 200
ids = {item["id"] for item in r.json().get("items", [])}
assert eid not in ids, (
"archived entity must NOT appear in browse listing"
)