* fix(store): surface review failures + harden publish gate
Four independent fixes to the flea-market submission pipeline, all surfaced
by an admin upload that landed at status='approved' without an LLM review.
1. LLM truncation no longer pins submissions in review_error.
- Raised MAX_RESPONSE_TOKENS 2500 → 6000 in llm_review.py
- Added one-shot retry-with-doubled-budget in anthropic_provider.py
(capped at 4× initial)
2. Flea detail page surfaces the latest submission's failure verdict even
when a previously-approved version is still serving (deferred-promotion
path). The _quarantine_banner gate widened from `visibility != approved`
to also fire on `blocked_inline / blocked_llm / review_error`, with copy
that distinguishes the v2+ edit case ("Latest edit failed review —
previously approved version (vN) keeps serving") from the initial-upload
quarantine wording.
3. Restore button + endpoint no longer allow restoring a version that was
never approved. Added StoreEntitiesRepository.get_with_version_approvals
joining store_submissions, gated the UI button on submission_status in
('approved', None), rendered status pills for non-restorable rows, and
added a 400 version_not_approved guard in POST /restore.
4. **BREAKING (operator-facing)**: publish gate is now fail-CLOSED on
misconfig. The previous get_guardrails_enabled() silently fell back to
"disabled, auto-approve everything" when guardrails.enabled=true in YAML
but no ANTHROPIC_API_KEY was in env. Split into:
- get_guardrails_enabled() (intent — YAML)
- get_guardrails_llm_provider_ready() (readiness — env)
Three-state matrix:
enabled=false → auto-approve (unchanged)
enabled=true + ready=true → normal pipeline (unchanged)
enabled=true + ready=false (NEW) → submissions hold at pending_llm
awaiting admin retry or override
(was: silent auto-approve)
Admin "Retry review" eligibility broadened to include pending_llm.
Boot-time WARNING banner surfaces the misconfig in app/main.py.
docs/STORE_GUARDRAILS.md updated with the three-state matrix.
Operators relying on the auto-fallback for local-dev no-LLM setups must
now explicitly set `guardrails.enabled: false` in instance.yaml.
Tests: 4623 passed. Added TestPublishGateFailClosed (4 tests) and
TestRestoreVersion::test_restore_rejects_* (3 tests). conftest.py adds an
autouse fixture defaulting guardrails OFF so legacy tests don't need to
know about the new toggle.
* fix(store): admin override promotes v2+ edits to current
The override handler at app/api/admin.py:3708 only flipped submission
status → 'overridden' and entity visibility → 'approved'. Under the v37+
deferred-promotion model that's insufficient for v2+ edits / restores:
the new bundle sits in versions/v<N>/plugin/ and the entity row stays at
the prior approved version_no + hash + on-disk live bundle. Installers
kept getting the OLD bytes the admin had just intended to replace.
Mirror the runner.run_llm_review auto-approval branch: look up the
submission's version_hash in entity.version_history, and if its `n`
differs from entity.version_no, promote_version + _swap_live_to_version.
Initial v1 overrides are unaffected — the loop finds n=1 == version_no
and skips promotion.
Tests:
- test_override_v2_edit_promotes_to_current: stage v1 approved + v2
blocked_llm; override the v2 sub; assert entity.version_no=2,
entity.version flips off the v1 hash, and the live plugin/ dir
mirrors versions/v2/plugin/.
- test_override_v1_initial_upload_no_promote: regression guard so the
promote loop doesn't accidentally bump a v1 override.
Audit log gains a promoted_to_version_no field on the override action.
* fix(store): retry/rescan review staged bundle; override forward-only
Two adversarial-review findings from a Codex pass on the publish-gate
work.
C1. Admin retry + rescan were passing live `plugin/` to the LLM. For a
v2+ submission held at `pending_llm` / `blocked_llm` / `review_error`,
live still holds the prior approved version's bytes — so the LLM
reviewed the WRONG bytes, and the runner's hash-match promotion in
`run_llm_review` would then advance the entity to staged bytes that
were never actually reviewed. Resolve the staged
`<entity>/versions/v<N>/plugin/` from the submission's
`version_history` entry, with a fall-back to live for legacy pre-v37
rows that never seeded a versions/ dir. Helpers
`_submission_plugin_dir` and `_version_no_for_submission` added to
`app/api/store.py` so override / retry / rescan share one path.
H1. Override's promote loop used `target != current`, which would
silently demote the live bundle when admin overrode a stale v2
submission while v3 was already approved + live. Changed to
`target > current` so override flips status + visibility on the row
regardless, but on-disk promotion only fires forward. Same `>`
defensive guard applied in `runner.run_llm_review` so a late LLM
verdict racing with a newer approval can't demote either.
Tests:
- TestAdminRetryReviewsStagedBundle::test_retry_v2_blocked_passes_staged_dir_not_live
- TestAdminRetryReviewsStagedBundle::test_rescan_v2_blocked_passes_staged_dir_not_live
- TestOverrideForwardOnly::test_override_stale_v2_does_not_demote_when_v3_current
* review polish: CHANGELOG drift, override eligibility, defensive copy
Three small additions on top of the retry/rescan staged-bundle fix:
1. CHANGELOG: the PR's bullets had drifted into the released
[0.54.17] section during rebase (context-match landed them next
to already-released content). Moved them up to [Unreleased] where
they belong; [0.54.17] now holds only what was actually released
(refresh-marketplace ls-remote, /me/activity hero, CI sharding +
workflow polish).
2. app/api/admin.py: admin override eligibility now accepts
pending_llm alongside blocked_inline + blocked_llm + review_error.
Closes a UX gap from the new fail-CLOSED behavior: under
enabled-but-not-ready, a known-good submission would otherwise
sit indefinitely until the admin set credentials AND clicked
Retry. Override already routes through version_history (and is
now forward-only on promote), so it stays safe for v2+ deferred-
promotion submissions.
3. src/repositories/store_entities.py: get_with_version_approvals
defensively copies each version_history entry before annotating
with submission_status. self.get() re-parses JSON each call today
so this is belt-and-suspenders against any future caching layer
leaking the annotated key into a subsequent plain get() call.
Tests: 112 passed (focused on test_store_entity_versions +
test_admin_store_submissions, covering the retry/rescan staged-
bundle fix the author shipped + this polish).
---------
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
1410 lines
59 KiB
Python
1410 lines
59 KiB
Python
"""v37 flea-market edit feature with version history.
|
|
|
|
Covers:
|
|
* Bundle update bumps version_no + appends version_history entry.
|
|
* Metadata-only edit doesn't bump version.
|
|
* Type change rejected with 400 type_locked.
|
|
* Block-while-pending: 409 prior_version_pending.
|
|
* Display name change renames the on-disk slug for live + version dirs.
|
|
* Restore copies a prior version forward as v<max+1>; live + history
|
|
reflect the new version; original version row keeps its own verdict.
|
|
* Restore re-runs guardrails (blocked path leaves live untouched).
|
|
* Versions card on detail page renders for owner/admin only.
|
|
"""
|
|
|
|
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.store_entities import StoreEntitiesRepository
|
|
from src.repositories.users import UserRepository
|
|
|
|
|
|
# Strong default description that clears the content guardrail's
|
|
# per-component bar (30 chars + 4 distinct words, no placeholder
|
|
# leftovers). Tests don't assert on its contents — they just need a
|
|
# value that passes review so we can exercise the edit/version path.
|
|
_OK_DESC = "Use when validating store version edit flow across every guardrail tier"
|
|
|
|
|
|
@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-edit@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, body: str = "Body line explaining the skill. " * 12) -> 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 verifying clean-bundle edits across the version-history lifecycle\n---\n\n"
|
|
+ body,
|
|
)
|
|
return buf.getvalue()
|
|
|
|
|
|
def _make_eval_skill_zip(skill_name: str) -> 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 verifying static-security rejects eval-using upload bundles cleanly\n---\n\n"
|
|
+ ("Body line explaining the skill. " * 12),
|
|
)
|
|
zf.writestr(f"{skill_name}/run.sh", "#!/bin/sh\neval $1\n")
|
|
return buf.getvalue()
|
|
|
|
|
|
def _upload_clean(client, cookies, name="ed1"):
|
|
r = client.post(
|
|
"/api/store/entities",
|
|
files={"file": ("s.zip", _make_skill_zip(name), "application/zip")},
|
|
data={"type": "skill", "description": _OK_DESC}, cookies=cookies,
|
|
)
|
|
assert r.status_code == 201, r.text
|
|
return r.json()["id"]
|
|
|
|
|
|
class TestEditFeature:
|
|
def test_metadata_only_edit_no_version_bump(self, web_client):
|
|
owner_id, owner_cookies = _create_user(web_client, "metaowner@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="metaedit")
|
|
|
|
r = web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
data={"description": "Updated description text"},
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
conn = get_system_db()
|
|
entity = StoreEntitiesRepository(conn).get(eid)
|
|
conn.close()
|
|
assert entity["description"] == "Updated description text"
|
|
assert entity["version_no"] == 1
|
|
assert len(entity["version_history"]) == 1
|
|
|
|
def test_bundle_edit_bumps_version_and_appends_history(self, web_client):
|
|
owner_id, owner_cookies = _create_user(web_client, "bundleowner@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="bundleedit")
|
|
|
|
# PUT with new bundle bytes.
|
|
new_zip = _make_skill_zip("bundleedit", body="V2 body. " * 80)
|
|
r = web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v2.zip", new_zip, "application/zip")},
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
conn = get_system_db()
|
|
entity = StoreEntitiesRepository(conn).get(eid)
|
|
conn.close()
|
|
assert entity["version_no"] == 2
|
|
assert len(entity["version_history"]) == 2
|
|
v1, v2 = entity["version_history"]
|
|
assert v1["n"] == 1
|
|
assert v2["n"] == 2
|
|
assert v2["hash"] != v1["hash"], "v2 hash must differ from v1"
|
|
|
|
def test_type_change_rejected(self, web_client):
|
|
owner_id, owner_cookies = _create_user(web_client, "typeowner@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="typelock")
|
|
r = web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
data={"type": "agent", "description": _OK_DESC},
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 400, r.text
|
|
assert r.json()["detail"]["code"] == "type_locked"
|
|
|
|
def test_block_while_prior_pending_409(self, web_client):
|
|
"""Manually flip the entity to visibility=pending + create a
|
|
pending submission, then attempt edit → 409."""
|
|
from src.repositories.store_submissions import StoreSubmissionsRepository
|
|
owner_id, owner_cookies = _create_user(web_client, "blockowner@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="blockpending")
|
|
|
|
conn = get_system_db()
|
|
# Force pending state.
|
|
conn.execute(
|
|
"UPDATE store_entities SET visibility_status = 'pending' WHERE id = ?",
|
|
[eid],
|
|
)
|
|
StoreSubmissionsRepository(conn).create(
|
|
submitter_id=owner_id, submitter_email="blockowner@x.com",
|
|
type="skill", name="blockpending", version="2.0.0",
|
|
status="pending_llm", entity_id=eid,
|
|
inline_checks={"manifest": {"status": "pass"}},
|
|
)
|
|
conn.close()
|
|
|
|
r = web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
data={"description": "Trying to edit"},
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 409, r.text
|
|
assert r.json()["detail"]["code"] == "prior_version_pending"
|
|
|
|
def test_name_change_renames_baked_slug(self, web_client):
|
|
owner_id, owner_cookies = _create_user(web_client, "renameowner@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="oldname")
|
|
|
|
r = web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
data={"name": "newname"},
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
conn = get_system_db()
|
|
entity = StoreEntitiesRepository(conn).get(eid)
|
|
conn.close()
|
|
assert entity["name"] == "newname"
|
|
|
|
plugin_dir = Path(get_store_dir()) / eid / "plugin"
|
|
new_skill_dir = plugin_dir / "skills" / "newname-by-renameowner"
|
|
old_skill_dir = plugin_dir / "skills" / "oldname-by-renameowner"
|
|
assert new_skill_dir.is_dir(), "renamed slug missing on disk"
|
|
assert not old_skill_dir.exists(), "old slug must be gone"
|
|
|
|
|
|
class TestRestoreVersion:
|
|
def test_restore_creates_new_version_with_old_bundle(self, web_client):
|
|
owner_id, owner_cookies = _create_user(web_client, "restoreowner@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="restoreme")
|
|
|
|
# Edit to v2.
|
|
v2_zip = _make_skill_zip("restoreme", body="VERSION-2-BODY " * 80)
|
|
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
|
|
|
|
# Capture v1 + v2 hashes from history.
|
|
conn = get_system_db()
|
|
entity = StoreEntitiesRepository(conn).get(eid)
|
|
conn.close()
|
|
v1_hash = entity["version_history"][0]["hash"]
|
|
v2_hash = entity["version_history"][1]["hash"]
|
|
assert v1_hash != v2_hash
|
|
|
|
# Restore v1 → creates v3 with v1's bundle hash.
|
|
r = web_client.post(
|
|
f"/api/store/entities/{eid}/versions/1/restore",
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
conn = get_system_db()
|
|
entity = StoreEntitiesRepository(conn).get(eid)
|
|
conn.close()
|
|
assert entity["version_no"] == 3
|
|
assert len(entity["version_history"]) == 3
|
|
v3 = entity["version_history"][2]
|
|
assert v3["n"] == 3
|
|
assert v3["hash"] == v1_hash, (
|
|
"restored bundle should hash identically to v1 — same bytes"
|
|
)
|
|
|
|
def test_restore_already_current_400(self, web_client):
|
|
owner_id, owner_cookies = _create_user(web_client, "alreadyowner@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="already")
|
|
r = web_client.post(
|
|
f"/api/store/entities/{eid}/versions/1/restore",
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 400, r.text
|
|
assert r.json()["detail"]["code"] == "already_current"
|
|
|
|
def test_restore_unknown_version_404(self, web_client):
|
|
owner_id, owner_cookies = _create_user(web_client, "unknownver@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="ukver")
|
|
r = web_client.post(
|
|
f"/api/store/entities/{eid}/versions/99/restore",
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 404, r.text
|
|
assert r.json()["detail"]["code"] == "version_not_found"
|
|
|
|
def test_non_owner_non_admin_cannot_restore(self, web_client):
|
|
owner_id, owner_cookies = _create_user(web_client, "owrestore@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="ownedver")
|
|
v2 = _make_skill_zip("ownedver", body="v2 " * 80)
|
|
web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v2.zip", v2, "application/zip")},
|
|
cookies=owner_cookies,
|
|
)
|
|
|
|
_, snoop_cookies = _create_user(web_client, "snoopver@x.com")
|
|
r = web_client.post(
|
|
f"/api/store/entities/{eid}/versions/1/restore",
|
|
cookies=snoop_cookies,
|
|
)
|
|
assert r.status_code in (403, 404), r.text
|
|
|
|
def test_restore_rejects_blocked_llm_version(self, web_client, monkeypatch):
|
|
"""A v2 that LLM-blocked sits in version_history with
|
|
submission.status='blocked_llm'. The restore endpoint must
|
|
refuse to roll forward from that bundle — defense in depth
|
|
against the UI being bypassed by direct POST."""
|
|
# Mock LLM to BLOCK v2.
|
|
def mock_review_bundle(*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_review_bundle,
|
|
)
|
|
|
|
owner_id, owner_cookies = _create_user(web_client, "blockrestore@x.com")
|
|
# Phase 1: guardrails OFF — v1 lands approved.
|
|
eid = _upload_clean(web_client, owner_cookies, name="blockrestore")
|
|
# Phase 2: guardrails ON → v2 blocked, entity stays at v1.
|
|
monkeypatch.setattr("app.api.store.get_guardrails_enabled", lambda: True)
|
|
monkeypatch.setattr("app.api.store.get_guardrails_llm_provider_ready", lambda: True)
|
|
v2 = _make_skill_zip("blockrestore", body="V2 BODY " * 80)
|
|
r = web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v2.zip", v2, "application/zip")},
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
# Drive the BG review synchronously.
|
|
from src.repositories.store_submissions import StoreSubmissionsRepository
|
|
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-test",
|
|
model_loader=lambda: "claude-haiku-4-5-20251001",
|
|
)
|
|
|
|
# Now POST a restore /versions/2/restore. Must 400 because v2
|
|
# was never approved.
|
|
r = web_client.post(
|
|
f"/api/store/entities/{eid}/versions/2/restore",
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 400, r.text
|
|
body = r.json()
|
|
assert body["detail"]["code"] == "version_not_approved"
|
|
assert body["detail"]["source_status"] == "blocked_llm"
|
|
|
|
def test_restore_rejects_review_error_version(self, web_client, monkeypatch):
|
|
"""Same as blocked_llm but the LLM call errored — the
|
|
submission row lands at 'review_error' and the version is
|
|
equally not-approvable."""
|
|
def mock_review_bundle(*args, **kwargs):
|
|
return {
|
|
"risk_level": None,
|
|
"summary": None,
|
|
"findings": [],
|
|
"template_placeholders_found": 0,
|
|
"reviewed_by_model": "mock-model",
|
|
"error": "LLMFormatError: mock truncation",
|
|
}
|
|
monkeypatch.setattr(
|
|
"src.store_guardrails.llm_review.review_bundle",
|
|
mock_review_bundle,
|
|
)
|
|
|
|
owner_id, owner_cookies = _create_user(web_client, "errrestore@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="errrestore")
|
|
monkeypatch.setattr("app.api.store.get_guardrails_enabled", lambda: True)
|
|
monkeypatch.setattr("app.api.store.get_guardrails_llm_provider_ready", lambda: True)
|
|
v2 = _make_skill_zip("errrestore", body="V2 BODY " * 80)
|
|
r = web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v2.zip", v2, "application/zip")},
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
from src.repositories.store_submissions import StoreSubmissionsRepository
|
|
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-test",
|
|
model_loader=lambda: "claude-haiku-4-5-20251001",
|
|
)
|
|
|
|
r = web_client.post(
|
|
f"/api/store/entities/{eid}/versions/2/restore",
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 400, r.text
|
|
body = r.json()
|
|
assert body["detail"]["code"] == "version_not_approved"
|
|
assert body["detail"]["source_status"] == "review_error"
|
|
|
|
def test_restore_allows_legacy_v1_without_submission_id(self, web_client):
|
|
"""The v1 seed entry created by ``StoreEntitiesRepository.create``
|
|
carries ``submission_id=None`` until the API layer backfills.
|
|
A restore targeting v1 must NOT be rejected just because the
|
|
join can't find a submission status — back-compat for entities
|
|
created before v37."""
|
|
owner_id, owner_cookies = _create_user(web_client, "legv1@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="legv1")
|
|
# Manually clear v1's submission_id to simulate the legacy seed.
|
|
conn = get_system_db()
|
|
repo = StoreEntitiesRepository(conn)
|
|
ent = repo.get(eid)
|
|
history = ent["version_history"]
|
|
history[0]["submission_id"] = None
|
|
conn.execute(
|
|
"UPDATE store_entities SET version_history = ? WHERE id = ?",
|
|
[json.dumps(history), eid],
|
|
)
|
|
conn.close()
|
|
|
|
# PUT a v2 so v1 is no longer current.
|
|
v2 = _make_skill_zip("legv1", body="V2 BODY " * 80)
|
|
r = web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v2.zip", v2, "application/zip")},
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
# Restore v1 → must succeed (200), because legacy v1 has
|
|
# submission_id=None which the guard treats as approved.
|
|
r = web_client.post(
|
|
f"/api/store/entities/{eid}/versions/1/restore",
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
|
class TestEditPage:
|
|
def test_edit_page_renders_for_owner(self, web_client):
|
|
owner_id, owner_cookies = _create_user(web_client, "editpage@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="editrender")
|
|
r = web_client.get(
|
|
f"/marketplace/flea/{eid}/edit", cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 200
|
|
assert "edit-form" in r.text
|
|
assert "editrender" in r.text
|
|
|
|
def test_edit_page_404_for_non_owner(self, web_client):
|
|
owner_id, owner_cookies = _create_user(web_client, "owneredit@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="snoopedit")
|
|
_, snoop_cookies = _create_user(web_client, "snoopedit@x.com")
|
|
r = web_client.get(
|
|
f"/marketplace/flea/{eid}/edit", cookies=snoop_cookies,
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
def test_versions_card_renders_for_owner(self, web_client):
|
|
owner_id, owner_cookies = _create_user(web_client, "vowner@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="vcard")
|
|
r = web_client.get(
|
|
f"/marketplace/flea/{eid}", cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 200
|
|
assert "versions-card" in r.text
|
|
assert "Versions (1)" in r.text
|
|
|
|
def test_versions_card_hidden_for_non_owner(self, web_client):
|
|
owner_id, owner_cookies = _create_user(web_client, "vowner2@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="vhide")
|
|
_, other_cookies = _create_user(web_client, "vother@x.com")
|
|
r = web_client.get(
|
|
f"/marketplace/flea/{eid}", cookies=other_cookies,
|
|
)
|
|
assert r.status_code == 200
|
|
assert "versions-card" not in r.text
|
|
|
|
|
|
class TestInstallerAlwaysGetsLatestApproved:
|
|
"""Critical contract: existing installers continue receiving the
|
|
last APPROVED version through the review window of a new edit, and
|
|
NEVER receive an unapproved version. If the new version is blocked,
|
|
they keep the prior approved one. If approved, they advance.
|
|
|
|
Implemented via deferred promotion: PUT/restore append the new
|
|
version to history at status='pending_llm' but DO NOT swap live
|
|
or bump entity.version_no. runner.run_llm_review's approval branch
|
|
promotes; on block, nothing changes.
|
|
"""
|
|
|
|
def _install_as_user(self, web_client, owner_cookies, eid):
|
|
"""Install as a separate consumer user, return their list_for_user
|
|
rows post-install (mirrors what marketplace.zip serves)."""
|
|
from src.repositories.user_store_installs import UserStoreInstallsRepository
|
|
installer_id, installer_cookies = _create_user(web_client, "installer@x.com")
|
|
r = web_client.post(
|
|
f"/api/store/entities/{eid}/install",
|
|
cookies=installer_cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
return installer_id, installer_cookies
|
|
|
|
def test_pending_review_does_not_break_existing_installer(self, web_client, monkeypatch):
|
|
"""Initial upload runs with guardrails OFF (lands approved).
|
|
Then we flip guardrails ON and PUT a new bundle. The new
|
|
version should defer promotion: existing installer must
|
|
continue seeing v1 + entity.version_no=1, not get hidden by a
|
|
flipped visibility or a half-promoted live dir."""
|
|
from app import instance_config as ic
|
|
# Stub LLM scheduling so the BG path never actually runs.
|
|
monkeypatch.setattr(
|
|
"app.api.store._schedule_llm_review",
|
|
lambda *a, **kw: None,
|
|
)
|
|
|
|
owner_id, owner_cookies = _create_user(web_client, "stickyowner@x.com")
|
|
# Phase 1: guardrails OFF → initial upload lands approved.
|
|
eid = _upload_clean(web_client, owner_cookies, name="sticky")
|
|
# Capture v1 hash + size baseline.
|
|
conn = get_system_db()
|
|
ent = StoreEntitiesRepository(conn).get(eid)
|
|
v1_hash = ent["version"]
|
|
v1_size = ent["file_size"]
|
|
conn.close()
|
|
|
|
# Install as a different user.
|
|
installer_id, _ = self._install_as_user(web_client, owner_cookies, eid)
|
|
|
|
# Phase 2: flip guardrails ON for the PUT call. Now an edit
|
|
# defers promotion until LLM approves.
|
|
# Patch where update_entity looks it up — `from app.instance_config
|
|
# import get_guardrails_enabled` binds the symbol into app.api.store,
|
|
# so patching the source module isn't enough.
|
|
monkeypatch.setattr("app.api.store.get_guardrails_enabled", lambda: True)
|
|
monkeypatch.setattr("app.api.store.get_guardrails_llm_provider_ready", lambda: True)
|
|
|
|
# PUT a new bundle. Guardrails on → submission lands at
|
|
# pending_llm; promotion deferred.
|
|
v2_zip = _make_skill_zip("sticky", body="V2 BODY " * 80)
|
|
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
|
|
|
|
# Installer's list_for_user must STILL see entity with v1 hash
|
|
# + size + visibility='approved'. The pending edit must not
|
|
# have hidden the entity from them.
|
|
from src.repositories.user_store_installs import UserStoreInstallsRepository
|
|
conn = get_system_db()
|
|
installs = UserStoreInstallsRepository(conn).list_for_user(installer_id)
|
|
ids = {r["id"] for r in installs}
|
|
assert eid in ids, (
|
|
"installer lost access to the entity during the LLM "
|
|
"review window — list_for_user filter excluded it"
|
|
)
|
|
row = next(r for r in installs if r["id"] == eid)
|
|
assert row["version"] == v1_hash, (
|
|
f"installer should still get v1 hash {v1_hash[:8]} but "
|
|
f"got {row['version'][:8]}"
|
|
)
|
|
assert row["file_size"] == v1_size, "size shouldn't change pre-promotion"
|
|
assert row["visibility_status"] == "approved", (
|
|
"entity must stay 'approved' through the review window so "
|
|
"existing installers continue serving"
|
|
)
|
|
|
|
# Entity row's version_no must NOT have bumped yet.
|
|
ent = StoreEntitiesRepository(conn).get(eid)
|
|
conn.close()
|
|
assert ent["version_no"] == 1, (
|
|
f"version_no must stay 1 during pending review; got {ent['version_no']}"
|
|
)
|
|
# But version_history MUST have v2 entry tracked (with the
|
|
# new hash) so admin can see what's in flight.
|
|
history_n = [int(e["n"]) for e in ent["version_history"]]
|
|
assert 2 in history_n, "v2 entry must be in history despite no promotion"
|
|
|
|
def test_blocked_new_version_keeps_installer_on_prior(self, web_client, monkeypatch):
|
|
"""Mock the LLM to BLOCK the v2 review. Installer must keep
|
|
v1; entity.version_no must stay at 1; live plugin/ must hold
|
|
v1's bytes."""
|
|
from app import instance_config as ic
|
|
|
|
# Mock the runner's LLM call to return a high-risk verdict.
|
|
def mock_review_bundle(*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_review_bundle,
|
|
)
|
|
|
|
# Phase 1: initial upload guardrails OFF.
|
|
owner_id, owner_cookies = _create_user(web_client, "blockowner@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="blocksticky")
|
|
# Phase 2: switch guardrails ON before PUT.
|
|
# Patch where update_entity looks it up — `from app.instance_config
|
|
# import get_guardrails_enabled` binds the symbol into app.api.store,
|
|
# so patching the source module isn't enough.
|
|
monkeypatch.setattr("app.api.store.get_guardrails_enabled", lambda: True)
|
|
monkeypatch.setattr("app.api.store.get_guardrails_llm_provider_ready", lambda: True)
|
|
conn = get_system_db()
|
|
v1_hash = StoreEntitiesRepository(conn).get(eid)["version"]
|
|
conn.close()
|
|
|
|
installer_id, _ = self._install_as_user(web_client, owner_cookies, eid)
|
|
|
|
# Edit. Inline checks pass; LLM mocked to block.
|
|
v2_zip = _make_skill_zip("blocksticky", body="v2-content " * 80)
|
|
# Run the LLM synchronously by calling runner directly after
|
|
# the PUT (the BG task may not have fired in TestClient).
|
|
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
|
|
|
|
# Find the just-created submission + run runner against it.
|
|
from src.repositories.store_submissions import StoreSubmissionsRepository
|
|
from src.store_guardrails.runner import run_llm_review
|
|
from app.utils import get_store_dir
|
|
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-test",
|
|
model_loader=lambda: "claude-haiku-4-5-20251001",
|
|
)
|
|
|
|
# Installer must STILL see v1.
|
|
from src.repositories.user_store_installs import UserStoreInstallsRepository
|
|
conn = get_system_db()
|
|
installs = UserStoreInstallsRepository(conn).list_for_user(installer_id)
|
|
ent = StoreEntitiesRepository(conn).get(eid)
|
|
conn.close()
|
|
|
|
row = next(r for r in installs if r["id"] == eid)
|
|
assert row["version"] == v1_hash, (
|
|
f"after v2 blocked, installer must still get v1 hash; got {row['version'][:8]}"
|
|
)
|
|
assert ent["version_no"] == 1, (
|
|
f"version_no must stay at 1 after a blocked verdict; got {ent['version_no']}"
|
|
)
|
|
|
|
def test_approved_new_version_promotes_to_installer(self, web_client):
|
|
"""Default test path: guardrails OFF → guardrails-disabled
|
|
promote-inline branch fires immediately. Installer's next
|
|
list_for_user reflects v2."""
|
|
owner_id, owner_cookies = _create_user(web_client, "promoowner@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="promosticky")
|
|
conn = get_system_db()
|
|
v1_hash = StoreEntitiesRepository(conn).get(eid)["version"]
|
|
conn.close()
|
|
|
|
installer_id, _ = self._install_as_user(web_client, owner_cookies, eid)
|
|
|
|
v2_zip = _make_skill_zip("promosticky", body="promo-v2 " * 80)
|
|
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
|
|
|
|
# Installer should now see v2 hash.
|
|
from src.repositories.user_store_installs import UserStoreInstallsRepository
|
|
conn = get_system_db()
|
|
installs = UserStoreInstallsRepository(conn).list_for_user(installer_id)
|
|
ent = StoreEntitiesRepository(conn).get(eid)
|
|
conn.close()
|
|
|
|
assert ent["version_no"] == 2
|
|
row = next(r for r in installs if r["id"] == eid)
|
|
assert row["version"] != v1_hash, "installer should advance to v2"
|
|
|
|
|
|
class TestAdminAccess:
|
|
"""Admin can edit + restore on entities they don't own (parity with
|
|
the existing admin override path)."""
|
|
|
|
def test_admin_can_edit_non_owned_entity_metadata(self, web_client):
|
|
owner_id, owner_cookies = _create_user(web_client, "adminedit-owner@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="adminedit")
|
|
_, admin_cookies = _create_admin(web_client)
|
|
r = web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
data={"description": "moderated by admin"},
|
|
cookies=admin_cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
conn = get_system_db()
|
|
ent = StoreEntitiesRepository(conn).get(eid)
|
|
conn.close()
|
|
assert ent["description"] == "moderated by admin"
|
|
assert ent["version_no"] == 1
|
|
|
|
def test_admin_can_restore_non_owned_entity(self, web_client):
|
|
owner_id, owner_cookies = _create_user(web_client, "adminrestore-owner@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="adminrestore")
|
|
v2 = _make_skill_zip("adminrestore", body="v2 " * 80)
|
|
web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v2.zip", v2, "application/zip")},
|
|
cookies=owner_cookies,
|
|
)
|
|
_, admin_cookies = _create_admin(web_client)
|
|
r = web_client.post(
|
|
f"/api/store/entities/{eid}/versions/1/restore",
|
|
cookies=admin_cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
conn = get_system_db()
|
|
ent = StoreEntitiesRepository(conn).get(eid)
|
|
conn.close()
|
|
assert ent["version_no"] == 3
|
|
|
|
|
|
class TestRestoreDeferredPromotion:
|
|
"""Restore endpoint mirrors PUT semantics: live + version_no stay
|
|
on prior current until LLM approves the restored copy."""
|
|
|
|
def test_restore_with_guardrails_on_does_not_promote_until_approved(
|
|
self, web_client, monkeypatch,
|
|
):
|
|
"""Owner restores v1 → restored bytes baked into v3 dir.
|
|
Until LLM approves, live + version_no stay at v2."""
|
|
owner_id, owner_cookies = _create_user(web_client, "restoreowner-defer@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="restoredefer")
|
|
v2 = _make_skill_zip("restoredefer", body="v2 " * 80)
|
|
web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v2.zip", v2, "application/zip")},
|
|
cookies=owner_cookies,
|
|
)
|
|
# v2 promoted (guardrails off). Now flip on for the restore.
|
|
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._schedule_llm_review",
|
|
lambda *a, **kw: None,
|
|
)
|
|
r = web_client.post(
|
|
f"/api/store/entities/{eid}/versions/1/restore",
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
conn = get_system_db()
|
|
ent = StoreEntitiesRepository(conn).get(eid)
|
|
conn.close()
|
|
# version_no must STAY at 2 until LLM approves the v3 (restored)
|
|
# copy. version_history has 3 entries; current is still 2.
|
|
assert ent["version_no"] == 2, (
|
|
f"restore must defer promotion when guardrails on; "
|
|
f"version_no={ent['version_no']}"
|
|
)
|
|
history_n = sorted([int(e["n"]) for e in ent["version_history"]])
|
|
assert history_n == [1, 2, 3]
|
|
|
|
|
|
class TestEditPageBanner:
|
|
"""Detail page banner during pending edit review must surface
|
|
the version under review + the prior version still serving."""
|
|
|
|
def test_banner_shows_review_error_when_prior_version_still_serving(
|
|
self, web_client, monkeypatch,
|
|
):
|
|
"""v2+ edit landing in review_error must surface a banner to
|
|
owner/admin even though entity stays at visibility=approved.
|
|
The original gate (visibility != approved) silently hid the
|
|
failure — see Bug #2 in plan
|
|
when-i-submitted-new-delightful-russell.md."""
|
|
from src.repositories.store_submissions import StoreSubmissionsRepository
|
|
|
|
# Mock LLM to ERROR on v2.
|
|
def mock_review_bundle(*args, **kwargs):
|
|
return {
|
|
"risk_level": None,
|
|
"summary": None,
|
|
"findings": [],
|
|
"template_placeholders_found": 0,
|
|
"reviewed_by_model": "mock-model",
|
|
"error": "LLMFormatError: mock truncation",
|
|
}
|
|
monkeypatch.setattr(
|
|
"src.store_guardrails.llm_review.review_bundle",
|
|
mock_review_bundle,
|
|
)
|
|
|
|
owner_id, owner_cookies = _create_user(web_client, "errbanner@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="errbanner")
|
|
monkeypatch.setattr("app.api.store.get_guardrails_enabled", lambda: True)
|
|
monkeypatch.setattr("app.api.store.get_guardrails_llm_provider_ready", lambda: True)
|
|
|
|
v2 = _make_skill_zip("errbanner", body="V2 BODY " * 80)
|
|
r = web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v2.zip", v2, "application/zip")},
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
# Drive the BG review synchronously so the submission row
|
|
# lands at review_error.
|
|
from src.store_guardrails.runner import run_llm_review
|
|
conn = get_system_db()
|
|
sub_id = StoreSubmissionsRepository(conn).latest_for_entity(eid)["id"]
|
|
ent = StoreEntitiesRepository(conn).get(eid)
|
|
conn.close()
|
|
assert ent["visibility_status"] == "approved", (
|
|
"deferred promotion: entity stays approved at prior version"
|
|
)
|
|
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-test",
|
|
model_loader=lambda: "claude-haiku-4-5-20251001",
|
|
)
|
|
|
|
# Confirm the submission really landed at review_error.
|
|
conn = get_system_db()
|
|
sub = StoreSubmissionsRepository(conn).get(sub_id)
|
|
ent = StoreEntitiesRepository(conn).get(eid)
|
|
conn.close()
|
|
assert sub["status"] == "review_error"
|
|
assert ent["visibility_status"] == "approved", (
|
|
"entity must remain approved — prior version still serving"
|
|
)
|
|
|
|
# Detail page MUST surface the failure.
|
|
r = web_client.get(
|
|
f"/marketplace/flea/{eid}", cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.text
|
|
# The widened v2+ review_error copy mentions the prior version
|
|
# still serving — that's the user-visible signal we just added.
|
|
assert "Latest edit failed review" in body, (
|
|
"banner partial must render review_error H3 for v2+ edit "
|
|
"when prior version still serves"
|
|
)
|
|
assert "previously approved version (v1)" in body, (
|
|
"banner copy must explain why the entity still appears live"
|
|
)
|
|
# The model's error string must reach the page so the owner
|
|
# can see what went wrong.
|
|
assert "LLMFormatError" in body
|
|
|
|
def test_banner_shows_version_n_under_review(self, web_client, monkeypatch):
|
|
from src.repositories.store_submissions import StoreSubmissionsRepository
|
|
owner_id, owner_cookies = _create_user(web_client, "bannerowner@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="bannerver")
|
|
|
|
# Switch guardrails on; stub LLM scheduler so v2 stays pending.
|
|
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._schedule_llm_review",
|
|
lambda *a, **kw: None,
|
|
)
|
|
|
|
v2 = _make_skill_zip("bannerver", body="v2 " * 80)
|
|
web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v2.zip", v2, "application/zip")},
|
|
cookies=owner_cookies,
|
|
)
|
|
# v2 review pending. visibility on entity stays approved (per
|
|
# the deferred-promotion fix). But banner partial reads sub
|
|
# status — for an in-flight edit submission the banner won't
|
|
# render unless visibility != approved. Lock the scenario:
|
|
# ensure entity stays at version_no=1 + visibility approved.
|
|
conn = get_system_db()
|
|
ent = StoreEntitiesRepository(conn).get(eid)
|
|
sub = StoreSubmissionsRepository(conn).latest_for_entity(eid)
|
|
conn.close()
|
|
assert ent["version_no"] == 1
|
|
assert ent["visibility_status"] == "approved"
|
|
assert sub["status"] == "pending_llm"
|
|
|
|
# Detail page renders. Banner partial only fires when
|
|
# visibility_status != approved, so for a deferred-edit case
|
|
# the marketplace detail does NOT render the quarantine
|
|
# banner — that's correct UX (consumers see the entity as
|
|
# approved and operational). Owner-facing review status
|
|
# surfaces via the Edit button being disabled.
|
|
r = web_client.get(
|
|
f"/marketplace/flea/{eid}", cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 200
|
|
# Edit button should reflect the in-flight review (locked).
|
|
assert "review in flight" in r.text or "Edit (review in flight)" in r.text
|
|
|
|
|
|
class TestAuditLogPerVersion:
|
|
"""Each edit / restore writes audit rows carrying the version_no
|
|
in params, so the entity timeline can attribute events to the
|
|
right version."""
|
|
|
|
def test_edit_audit_carries_version_no(self, web_client):
|
|
from src.repositories.audit import AuditRepository
|
|
owner_id, owner_cookies = _create_user(web_client, "auditver@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="auditver")
|
|
v2 = _make_skill_zip("auditver", body="v2 " * 80)
|
|
web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v2.zip", v2, "application/zip")},
|
|
cookies=owner_cookies,
|
|
)
|
|
|
|
conn = get_system_db()
|
|
rows = AuditRepository(conn).query_for_resources(
|
|
[f"store_entity:{eid}"], limit=20,
|
|
)
|
|
conn.close()
|
|
# store.entity.update event for the edit must carry version_no
|
|
# in its params.
|
|
update_rows = [r for r in rows if r.get("action") == "store.entity.update"]
|
|
assert update_rows, "missing store.entity.update audit"
|
|
assert any(
|
|
(r.get("params") or {}).get("version_no") == 2
|
|
for r in update_rows
|
|
), "update audit must carry version_no=2 in params"
|
|
|
|
def test_restore_audit_carries_versions(self, web_client):
|
|
from src.repositories.audit import AuditRepository
|
|
owner_id, owner_cookies = _create_user(web_client, "auditrestore@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="auditrest")
|
|
v2 = _make_skill_zip("auditrest", body="v2 " * 80)
|
|
web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v2.zip", v2, "application/zip")},
|
|
cookies=owner_cookies,
|
|
)
|
|
web_client.post(
|
|
f"/api/store/entities/{eid}/versions/1/restore",
|
|
cookies=owner_cookies,
|
|
)
|
|
|
|
conn = get_system_db()
|
|
rows = AuditRepository(conn).query_for_resources(
|
|
[f"store_entity:{eid}"], limit=20,
|
|
)
|
|
conn.close()
|
|
restore_rows = [r for r in rows if r.get("action") == "store.entity.restore"]
|
|
assert restore_rows, "missing store.entity.restore audit"
|
|
params = restore_rows[0].get("params") or {}
|
|
assert params.get("restored_from_version_no") == 1
|
|
assert params.get("new_version_no") == 3
|
|
|
|
|
|
class TestPRReviewFixes:
|
|
"""Locks in the fixes called out in the PR #239 review."""
|
|
|
|
def test_block_while_pending_fires_for_v2_edit_under_deferred_promotion(
|
|
self, web_client, monkeypatch,
|
|
):
|
|
"""#1 — v2+ edit during in-flight LLM review must 409 even
|
|
though entity.visibility_status is still 'approved'."""
|
|
from src.repositories.store_submissions import StoreSubmissionsRepository
|
|
owner_id, owner_cookies = _create_user(web_client, "blockv2@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="blockv2")
|
|
|
|
# Switch guardrails on for the edit so promotion defers.
|
|
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._schedule_llm_review",
|
|
lambda *a, **kw: None,
|
|
)
|
|
|
|
v2 = _make_skill_zip("blockv2", body="v2 " * 80)
|
|
r = web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v2.zip", v2, "application/zip")},
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
# Entity should remain at 'approved' visibility — v2 in flight.
|
|
conn = get_system_db()
|
|
ent = StoreEntitiesRepository(conn).get(eid)
|
|
sub = StoreSubmissionsRepository(conn).latest_for_entity(eid)
|
|
conn.close()
|
|
assert ent["visibility_status"] == "approved"
|
|
assert sub["status"] == "pending_llm"
|
|
|
|
# Second concurrent edit MUST be blocked.
|
|
v3 = _make_skill_zip("blockv2", body="v3 " * 80)
|
|
r = web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v3.zip", v3, "application/zip")},
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 409, r.text
|
|
assert r.json()["detail"]["code"] == "prior_version_pending"
|
|
|
|
def test_name_change_with_bundle_does_not_rename_live_until_promote(
|
|
self, web_client, monkeypatch,
|
|
):
|
|
"""#2 — name + bundle in same PUT must NOT rename live until
|
|
the LLM approves and promotion runs. Existing installer keeps
|
|
getting the prior bundle under the prior slug."""
|
|
from app.utils import get_store_dir
|
|
owner_id, owner_cookies = _create_user(web_client, "rename-defer@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="origname")
|
|
|
|
plugin_dir = Path(get_store_dir()) / eid / "plugin"
|
|
old_skill_dir = plugin_dir / "skills" / "origname-by-rename-defer"
|
|
assert old_skill_dir.is_dir()
|
|
|
|
# Enable guardrails so promotion defers.
|
|
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._schedule_llm_review",
|
|
lambda *a, **kw: None,
|
|
)
|
|
|
|
v2 = _make_skill_zip("origname", body="v2 " * 80)
|
|
r = web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v2.zip", v2, "application/zip")},
|
|
data={"name": "newname"},
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
# Live skill dir MUST still hold the old slug — promotion
|
|
# hasn't fired since we stubbed _schedule_llm_review.
|
|
assert old_skill_dir.is_dir(), (
|
|
"live skill dir was renamed before LLM approval — "
|
|
"violates deferred-promotion contract"
|
|
)
|
|
new_skill_dir = plugin_dir / "skills" / "newname-by-rename-defer"
|
|
assert not new_skill_dir.exists(), (
|
|
"live shouldn't have the renamed slug yet"
|
|
)
|
|
# Version dir HAS been renamed (so promotion will land on the
|
|
# new slug).
|
|
v2_dir = (
|
|
Path(get_store_dir()) / eid / "versions" / "v2" / "plugin"
|
|
/ "skills" / "newname-by-rename-defer"
|
|
)
|
|
assert v2_dir.is_dir(), "version dir should carry the new slug"
|
|
|
|
def test_v2_approval_logs_approved_not_skipped(
|
|
self, web_client, monkeypatch,
|
|
):
|
|
"""#3 — v2+ approvals must log store.submission.approved, NOT
|
|
store.submission.bg_verdict_skipped. Pre-fix the runner used
|
|
the visibility-flip return value to gate the audit; under
|
|
deferred promotion v2+ never flips visibility (already
|
|
'approved'), so the wrong audit was emitted."""
|
|
from src.repositories.audit import AuditRepository
|
|
from src.repositories.store_submissions import StoreSubmissionsRepository
|
|
from src.store_guardrails.runner import run_llm_review
|
|
from app.utils import get_store_dir
|
|
|
|
owner_id, owner_cookies = _create_user(web_client, "auditv2@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="auditv2")
|
|
|
|
# Enable guardrails for the edit. Stub LLM scheduler so we
|
|
# control when the runner fires.
|
|
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._schedule_llm_review",
|
|
lambda *a, **kw: None,
|
|
)
|
|
# Mock the LLM call to return a safe verdict.
|
|
monkeypatch.setattr(
|
|
"src.store_guardrails.llm_review.review_bundle",
|
|
lambda *a, **kw: {
|
|
"risk_level": "safe", "summary": "ok",
|
|
"findings": [], "template_placeholders_found": 0,
|
|
"reviewed_by_model": "mock", "error": None,
|
|
},
|
|
)
|
|
|
|
v2 = _make_skill_zip("auditv2", body="v2 " * 80)
|
|
web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v2.zip", v2, "application/zip")},
|
|
cookies=owner_cookies,
|
|
)
|
|
|
|
# Manually fire the runner against the v2 dir.
|
|
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-test",
|
|
model_loader=lambda: "claude-haiku-4-5-20251001",
|
|
)
|
|
|
|
# Audit log must contain store.submission.approved with
|
|
# promoted_to_version_no=2; NO bg_verdict_skipped.
|
|
conn = get_system_db()
|
|
rows = AuditRepository(conn).query_for_resources(
|
|
[f"store_submission:{sub_id}"], limit=20,
|
|
)
|
|
conn.close()
|
|
actions = [r.get("action") for r in rows]
|
|
assert "store.submission.approved" in actions, (
|
|
f"v2 approval missing approved audit; got {actions}"
|
|
)
|
|
assert "store.submission.bg_verdict_skipped" not in actions, (
|
|
f"v2 approval should NOT log bg_verdict_skipped; got {actions}"
|
|
)
|
|
approved_row = next(
|
|
r for r in rows if r.get("action") == "store.submission.approved"
|
|
)
|
|
params = approved_row.get("params") or {}
|
|
assert params.get("promoted_to_version_no") == 2
|
|
|
|
def test_bg_verdict_skipped_when_admin_archives_during_review(
|
|
self, web_client, monkeypatch,
|
|
):
|
|
"""Negative: when admin DOES archive mid-review, the runner
|
|
correctly logs bg_verdict_skipped (not approved)."""
|
|
from src.repositories.audit import AuditRepository
|
|
from src.repositories.store_submissions import StoreSubmissionsRepository
|
|
from src.repositories.store_entities import StoreEntitiesRepository
|
|
from src.store_guardrails.runner import run_llm_review
|
|
from app.utils import get_store_dir
|
|
|
|
owner_id, owner_cookies = _create_user(web_client, "archmid@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="archmid")
|
|
|
|
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._schedule_llm_review",
|
|
lambda *a, **kw: None,
|
|
)
|
|
monkeypatch.setattr(
|
|
"src.store_guardrails.llm_review.review_bundle",
|
|
lambda *a, **kw: {
|
|
"risk_level": "safe", "summary": "ok",
|
|
"findings": [], "template_placeholders_found": 0,
|
|
"reviewed_by_model": "mock", "error": None,
|
|
},
|
|
)
|
|
|
|
v2 = _make_skill_zip("archmid", body="v2 " * 80)
|
|
web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v2.zip", v2, "application/zip")},
|
|
cookies=owner_cookies,
|
|
)
|
|
|
|
# Admin archives BEFORE runner fires.
|
|
conn = get_system_db()
|
|
StoreEntitiesRepository(conn).archive(eid, by_user_id="admin-x")
|
|
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-test",
|
|
model_loader=lambda: "claude-haiku-4-5-20251001",
|
|
)
|
|
|
|
conn = get_system_db()
|
|
rows = AuditRepository(conn).query_for_resources(
|
|
[f"store_submission:{sub_id}"], limit=20,
|
|
)
|
|
conn.close()
|
|
actions = [r.get("action") for r in rows]
|
|
assert "store.submission.bg_verdict_skipped" in actions, (
|
|
f"archive-during-review must log bg_verdict_skipped; got {actions}"
|
|
)
|
|
assert "store.submission.approved" not in actions, (
|
|
f"archive-during-review must NOT log approved; got {actions}"
|
|
)
|
|
|
|
|
|
class TestAdminQueueShowsVersion:
|
|
def test_admin_queue_shows_v_no_after_name(self, web_client):
|
|
"""v# column derives version_no from entity.version_history by
|
|
matching submission.version (hash) against the entry hashes."""
|
|
owner_id, owner_cookies = _create_user(web_client, "vqowner@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="vqcol")
|
|
v2 = _make_skill_zip("vqcol", body="v2 body. " * 80)
|
|
web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v2.zip", v2, "application/zip")},
|
|
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
|
|
items = {it["name"]: it for it in r.json()["items"]}
|
|
assert "vqcol" in items
|
|
# version_no derived for the v2 row should be 2.
|
|
# The list returns rows newest-first; pick the v2 (current).
|
|
v2_row = next(
|
|
it for it in r.json()["items"]
|
|
if it.get("entity_id") == eid and it.get("version_no") == 2
|
|
)
|
|
assert v2_row["version_no"] == 2
|
|
assert v2_row["entity_version_no"] == 2
|
|
|
|
def test_admin_detail_shows_version_no(self, web_client):
|
|
"""Detail page renders v# under the Status / Entity-lifecycle
|
|
block + a separate Bundle hash row."""
|
|
from src.repositories.store_submissions import StoreSubmissionsRepository
|
|
owner_id, owner_cookies = _create_user(web_client, "vdowner@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="vdrow")
|
|
|
|
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
|
|
body = r.text
|
|
assert "<dt>Version</dt>" in body
|
|
assert "<dt>Bundle hash</dt>" in body
|
|
assert "v1" in body
|
|
|
|
|
|
class TestPublishGateFailClosed:
|
|
"""Hold-for-review when ``guardrails.enabled: true`` but no LLM
|
|
provider credentials are present in env. The pre-v45 fall-back
|
|
silently auto-approved every upload — a fail-OPEN hole the
|
|
operator couldn't notice. New behavior: submissions sit at
|
|
``pending_llm``, entity stays at ``visibility_status='pending'``,
|
|
admin retries from /admin/store/submissions after providing
|
|
credentials."""
|
|
|
|
def test_v1_upload_enabled_but_not_ready_holds_at_pending(
|
|
self, web_client, monkeypatch,
|
|
):
|
|
from src.repositories.store_submissions import StoreSubmissionsRepository
|
|
# Flip guardrails ON but leave provider_ready as False.
|
|
monkeypatch.setattr(
|
|
"app.api.store.get_guardrails_enabled", lambda: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.api.store.get_guardrails_llm_provider_ready", lambda: False,
|
|
)
|
|
# No mock review_bundle — we should never call the LLM.
|
|
# If we did, the lack of patching would surface as a real
|
|
# network call attempt, easy to catch as a hang.
|
|
|
|
owner_id, owner_cookies = _create_user(web_client, "holdv1@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="holdv1")
|
|
|
|
conn = get_system_db()
|
|
ent = StoreEntitiesRepository(conn).get(eid)
|
|
sub = StoreSubmissionsRepository(conn).latest_for_entity(eid)
|
|
conn.close()
|
|
assert ent["visibility_status"] == "pending", (
|
|
"enabled-but-not-ready must NOT publish — entity stays pending"
|
|
)
|
|
assert sub["status"] == "pending_llm", (
|
|
"submission must hold at pending_llm awaiting admin retry"
|
|
)
|
|
assert sub["llm_findings"] is None, (
|
|
"no LLM call was made — findings must be empty"
|
|
)
|
|
|
|
def test_admin_retry_pending_llm_fires_review(
|
|
self, web_client, monkeypatch,
|
|
):
|
|
"""After the operator sets the API key, admin Retry-review on a
|
|
held pending_llm row schedules + runs the LLM."""
|
|
from src.repositories.store_submissions import StoreSubmissionsRepository
|
|
|
|
# Phase 1: upload with provider not-ready → held at pending_llm.
|
|
monkeypatch.setattr(
|
|
"app.api.store.get_guardrails_enabled", lambda: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.api.store.get_guardrails_llm_provider_ready", lambda: False,
|
|
)
|
|
owner_id, owner_cookies = _create_user(web_client, "retryholder@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="retryholder")
|
|
|
|
conn = get_system_db()
|
|
sub_id = StoreSubmissionsRepository(conn).latest_for_entity(eid)["id"]
|
|
conn.close()
|
|
|
|
# Phase 2: operator adds credentials, admin retries.
|
|
# Inject a fake env var so default_api_key_loader doesn't raise
|
|
# before the mock review_bundle runs.
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test-fake-key-for-retry")
|
|
# Mock review_bundle so the retry resolves to approved without
|
|
# touching the network.
|
|
def mock_review_bundle(*args, **kwargs):
|
|
return {
|
|
"risk_level": "safe", "summary": "ok",
|
|
"findings": [], "template_placeholders_found": 0,
|
|
"reviewed_by_model": "mock-model", "error": None,
|
|
"content_quality": {"verdict": "pass", "issues": []},
|
|
}
|
|
monkeypatch.setattr(
|
|
"src.store_guardrails.llm_review.review_bundle",
|
|
mock_review_bundle,
|
|
)
|
|
|
|
_, 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
|
|
# After retry, BG task runs synchronously in TestClient (it
|
|
# blocks the response). Verify the row moved to approved.
|
|
conn = get_system_db()
|
|
sub = StoreSubmissionsRepository(conn).get(sub_id)
|
|
ent = StoreEntitiesRepository(conn).get(eid)
|
|
conn.close()
|
|
assert sub["status"] == "approved", (
|
|
f"retry must drive submission to approved; got {sub['status']}"
|
|
)
|
|
assert ent["visibility_status"] == "approved", (
|
|
"entity must flip to approved after LLM ok"
|
|
)
|
|
|
|
def test_edit_enabled_but_not_ready_holds_prior_serving(
|
|
self, web_client, monkeypatch,
|
|
):
|
|
"""v2+ edit under enabled-but-not-ready: v1 keeps serving,
|
|
v2 submission held at pending_llm. Critical safety property:
|
|
no silent promotion."""
|
|
from src.repositories.store_submissions import StoreSubmissionsRepository
|
|
# Initial upload runs with guardrails OFF (autouse default) →
|
|
# v1 approved. Then flip to enabled-but-not-ready for PUT.
|
|
owner_id, owner_cookies = _create_user(web_client, "holdedit@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="holdedit")
|
|
conn = get_system_db()
|
|
v1_hash = StoreEntitiesRepository(conn).get(eid)["version"]
|
|
conn.close()
|
|
|
|
monkeypatch.setattr(
|
|
"app.api.store.get_guardrails_enabled", lambda: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.api.store.get_guardrails_llm_provider_ready", lambda: False,
|
|
)
|
|
v2 = _make_skill_zip("holdedit", body="V2 BODY " * 80)
|
|
r = web_client.put(
|
|
f"/api/store/entities/{eid}",
|
|
files={"file": ("v2.zip", v2, "application/zip")},
|
|
cookies=owner_cookies,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
conn = get_system_db()
|
|
ent = StoreEntitiesRepository(conn).get(eid)
|
|
sub = StoreSubmissionsRepository(conn).latest_for_entity(eid)
|
|
conn.close()
|
|
# Entity stays approved at v1, v2 sits at pending_llm.
|
|
assert ent["visibility_status"] == "approved"
|
|
assert ent["version_no"] == 1
|
|
assert ent["version"] == v1_hash, (
|
|
"live bundle must remain v1 — no silent promotion of v2"
|
|
)
|
|
assert sub["status"] == "pending_llm"
|
|
assert sub["llm_findings"] is None
|
|
|
|
def test_disabled_intent_still_auto_approves(
|
|
self, web_client, monkeypatch,
|
|
):
|
|
"""Operator explicitly opting out (``enabled: false``) keeps
|
|
the prior auto-approve behavior — local dev / no-LLM
|
|
deployments aren't blocked."""
|
|
from src.repositories.store_submissions import StoreSubmissionsRepository
|
|
# autouse fixture already sets enabled=False. Just confirm
|
|
# behavior end-to-end.
|
|
owner_id, owner_cookies = _create_user(web_client, "offowner@x.com")
|
|
eid = _upload_clean(web_client, owner_cookies, name="offowner")
|
|
|
|
conn = get_system_db()
|
|
ent = StoreEntitiesRepository(conn).get(eid)
|
|
sub = StoreSubmissionsRepository(conn).latest_for_entity(eid)
|
|
conn.close()
|
|
assert ent["visibility_status"] == "approved"
|
|
assert sub["status"] == "approved"
|