fix(store): security + correctness blockers found in PR review (F1, F2, F4, F5)
Three independent reviews of PR #180 surfaced four real defects in the new Store / my-ai-stack surface. CHANGELOG entries detail each; one-liners: - F1 video_url XSS: any authenticated user could upload a Store entity with `video_url=javascript:...` and pop XSS in any viewer's session via the `<a href=...>` "Watch video" link in store_detail.html. Jinja2 autoescape doesn't block URI schemes inside attribute values. Fixed by scheme-validating to http(s) only on create + update; 400 invalid_video_url. - F2 ZIP decompression bomb: _safe_zip_extract checked path-traversal but not declared file_size totals — a 50 MB compressed upload at 1:1000 ratio decompresses to 50 GB and DOS the host disk. Fixed by summing zinfo.file_size across infolist() and refusing > 200 MB before extractall touches disk. 413 zip_too_large_uncompressed. - F4 admin authz parity: PUT /api/store/entities/{id} was owner-only while DELETE allowed owner OR admin; the store-detail page hid Edit/Delete buttons from admin even though DELETE was permitted. Fixed by allowing admin on PUT and passing is_admin to the template; gate is now is_owner OR is_admin everywhere. - F5 cross-owner suffix collision: sanitize_username is many-to-one (alice.smith / alice_smith both → alice-smith). Two such users uploading entities with the same display name produced identical `<name>-by-<username>` suffixes, silently colliding in the served agnes-store-bundle on-disk paths AND the manifest catalog (Claude Code dedupes by plugin.json `name`). Fixed by enforcing global uniqueness on the suffixed value at create_entity; 409 conflict_global_suffix. F3 (ZIP symlink members) was investigated and confirmed to be a false-positive — Python's stdlib ZipFile.extractall does not honor symlink mode bits, so no exploit exists. 9 new regression tests in tests/test_store_api.py::TestStoreSecurityFixes covering all four. Test run locally: 60/60 store-related tests pass.
This commit is contained in:
parent
537ea7662b
commit
fd3c76d21b
6 changed files with 381 additions and 5 deletions
39
CHANGELOG.md
39
CHANGELOG.md
|
|
@ -10,6 +10,8 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.35.0] — 2026-05-05
|
||||
|
||||
### Added
|
||||
- **`/store` page** — community marketplace where every authenticated user
|
||||
can upload skills, agents, and plugins as ZIPs. Listing has type / category /
|
||||
|
|
@ -43,6 +45,11 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
|||
`GET /api/store/categories`, `GET /api/store/owners`,
|
||||
`GET /api/my-stack`,
|
||||
`PUT /api/my-stack/curated/{marketplace_id}/{plugin_name}`.
|
||||
- **CLI: `agnes store {list,show,install,uninstall,upload,delete}`** and
|
||||
**`agnes my-stack {show,toggle}`** — full analyst-side coverage of the
|
||||
new Store + composition REST surface. Multipart upload helper added to
|
||||
`cli/v2_client.py` (`api_post_multipart` / `api_put_multipart`) so
|
||||
future multipart endpoints don't have to roll their own httpx wiring.
|
||||
|
||||
### Changed
|
||||
- `/admin/marketplaces` admin nav entry moved from the top-level header into
|
||||
|
|
@ -57,6 +64,32 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
|||
`/marketplace/info` payload now splits its `plugins` array by `source`,
|
||||
exposing `plugins` (admin) and `store_plugins` (community).
|
||||
|
||||
### Fixed
|
||||
- **Stored XSS via `video_url`** (`app/api/store.py`) — `video_url` accepted
|
||||
on `POST/PUT /api/store/entities` is now scheme-validated to `http(s)://`
|
||||
only. Previously a `javascript:` URI flowed through the form field into
|
||||
`store_detail.html`'s `<a href>` and would execute in any viewer's
|
||||
session on click. 400 `invalid_video_url` on bad input.
|
||||
- **ZIP decompression bomb** (`app/api/store.py:_safe_zip_extract`) — the
|
||||
uncompressed-side total of an upload is now capped at 200 MB
|
||||
(`MAX_ZIP_UNCOMPRESSED`); the compressed-side cap (50 MB) alone did not
|
||||
bound the on-disk footprint. 413 `zip_too_large_uncompressed` on
|
||||
oversize.
|
||||
- **Admin authz parity for Store mutations** (`app/api/store.py`,
|
||||
`app/web/router.py`, `app/web/templates/store_detail.html`) —
|
||||
`PUT /api/store/entities/{id}` now permits owner OR admin (matches
|
||||
`DELETE`); the store-detail page passes `is_admin` to the template and
|
||||
gates the Edit/Delete buttons on `is_owner OR is_admin`. Pre-fix, an
|
||||
admin could delete via the API but saw no Edit/Delete affordance in the
|
||||
UI, and could not update non-owned entities at all.
|
||||
- **Cross-owner suffix collision** (`app/api/store.py:create_entity`) —
|
||||
`sanitize_username` is many-to-one (`alice.smith` and `alice_smith`
|
||||
both → `alice-smith`). Two such users uploading entities with the same
|
||||
display `name` produced identical `<name>-by-<username>` suffixes,
|
||||
silently colliding in the served bundle's on-disk paths and the
|
||||
manifest catalog (Claude Code dedupes by `plugin.json`'s `name`).
|
||||
We now refuse the second upload with 409 `conflict_global_suffix`.
|
||||
|
||||
### Internal
|
||||
- Schema **v24 → v25**: adds `store_entities`, `user_store_installs`,
|
||||
`user_plugin_optouts`. Auto-migration via `_V24_TO_V25_MIGRATIONS` ladder
|
||||
|
|
@ -70,6 +103,12 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
|||
returns, parameterized SQL, no ORM).
|
||||
- `app/utils.py:get_store_dir()` — `${DATA_DIR}/store/`.
|
||||
- `humanbytes` Jinja2 filter on Store detail page (binary KB/MB/GB).
|
||||
- New CLI command modules: `cli/commands/store.py`, `cli/commands/my_stack.py`.
|
||||
Registered as Typer subapps `agnes store` and `agnes my-stack` in
|
||||
`cli/main.py`. Tests at `tests/test_cli_store.py`.
|
||||
- `tests/test_store_api.py:TestStoreSecurityFixes` — regression suite for
|
||||
F1 (video_url), F2 (zip-bomb), F4 (admin authz parity), F5 (cross-owner
|
||||
suffix collision).
|
||||
|
||||
## [0.34.0] — 2026-05-04
|
||||
|
||||
|
|
|
|||
104
app/api/store.py
104
app/api/store.py
|
|
@ -27,6 +27,7 @@ import zipfile
|
|||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import duckdb
|
||||
from fastapi import (
|
||||
|
|
@ -66,6 +67,63 @@ _CHUNK_SIZE = 64 * 1024
|
|||
_VALID_TYPES = {"skill", "agent", "plugin"}
|
||||
_NAME_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
||||
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
|
||||
_ALLOWED_VIDEO_SCHEMES = {"http", "https"}
|
||||
|
||||
# Cap on uncompressed total size of an uploaded ZIP. The compressed-side cap
|
||||
# is MAX_ZIP_SIZE; an attacker could craft a 50 MB ZIP that decompresses to
|
||||
# >>10 GB and DOS the host disk via _safe_zip_extract. We sum infolist()
|
||||
# file_size before extracting and refuse anything above this bound.
|
||||
MAX_ZIP_UNCOMPRESSED = 200 * 1024 * 1024 # 200 MB
|
||||
|
||||
|
||||
def _suffixed_already_taken(
|
||||
conn: duckdb.DuckDBPyConnection,
|
||||
suffixed: str,
|
||||
*,
|
||||
exclude_entity_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Whether any existing entity ships the same display+invocation name.
|
||||
|
||||
The Store namespace is **flat** in Claude Code — two plugins/skills/agents
|
||||
that share a ``name`` collide in the served marketplace catalog (the
|
||||
``manifest_name`` is unique-key for ``/plugin`` lookup) and on-disk inside
|
||||
the ``agnes-store-bundle`` (skills/<suffixed>/SKILL.md is the dir name).
|
||||
|
||||
``sanitize_username`` is many-to-one (``alice.smith`` and ``alice_smith``
|
||||
both → ``alice-smith``), so the per-owner UNIQUE on
|
||||
``(owner_user_id, name)`` does NOT prevent the cross-owner collision.
|
||||
We enforce global uniqueness on ``name || '-by-' || owner_username``
|
||||
here, at upload time, with a clear 409.
|
||||
"""
|
||||
sql = (
|
||||
"SELECT id FROM store_entities "
|
||||
"WHERE name || '-by-' || owner_username = ?"
|
||||
)
|
||||
params: List[Any] = [suffixed]
|
||||
if exclude_entity_id:
|
||||
sql += " AND id != ?"
|
||||
params.append(exclude_entity_id)
|
||||
return bool(conn.execute(sql, params).fetchone())
|
||||
|
||||
|
||||
def _validate_video_url(value: Optional[str]) -> Optional[str]:
|
||||
"""Return the URL if it is a safe http(s) URL, raise 400 otherwise.
|
||||
|
||||
Empty / None passes through as None — video_url is optional. Defends
|
||||
against ``javascript:``, ``data:``, ``vbscript:`` (etc.) URIs that
|
||||
would execute in the viewer's session if rendered inside an ``href``.
|
||||
Jinja2 autoescape only HTML-escapes characters; it does not block URI
|
||||
schemes inside attribute values.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
s = value.strip()
|
||||
if not s:
|
||||
return None
|
||||
parsed = urlparse(s)
|
||||
if parsed.scheme.lower() not in _ALLOWED_VIDEO_SCHEMES or not parsed.netloc:
|
||||
raise HTTPException(status_code=400, detail="invalid_video_url")
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -251,11 +309,24 @@ async def _stream_to_temp(
|
|||
|
||||
|
||||
def _safe_zip_extract(zf: zipfile.ZipFile, dest: Path) -> None:
|
||||
"""Extract ``zf`` into ``dest`` while rejecting any member whose normalized
|
||||
path would escape ``dest`` (zip-slip guard)."""
|
||||
"""Extract ``zf`` into ``dest`` while rejecting unsafe members.
|
||||
|
||||
Three guards:
|
||||
|
||||
1. Path traversal (zip-slip) — refuse absolute paths or ``..`` segments.
|
||||
2. Decompression bomb — reject if the sum of declared uncompressed sizes
|
||||
exceeds ``MAX_ZIP_UNCOMPRESSED``. The compressed-side cap
|
||||
(``MAX_ZIP_SIZE``) does not bound the decompressed footprint; a 50 MB
|
||||
ZIP at ratio 1:1000 expands to 50 GB on disk.
|
||||
3. (Note) Python's stdlib ``ZipFile.extractall`` does NOT honor symlink
|
||||
mode bits — symlink entries are written as regular files containing
|
||||
the link target text, not as actual symlinks. So no extra symlink
|
||||
guard is needed for the stdlib path.
|
||||
"""
|
||||
dest_resolved = dest.resolve()
|
||||
total_uncompressed = 0
|
||||
for member in zf.infolist():
|
||||
# Reject absolute paths and any traversal segments.
|
||||
# Path traversal.
|
||||
member_path = Path(member.filename)
|
||||
if member_path.is_absolute() or any(part == ".." for part in member_path.parts):
|
||||
raise HTTPException(status_code=422, detail="zip_unsafe_path")
|
||||
|
|
@ -264,6 +335,19 @@ def _safe_zip_extract(zf: zipfile.ZipFile, dest: Path) -> None:
|
|||
target.relative_to(dest_resolved)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail="zip_unsafe_path")
|
||||
|
||||
# Decompression bomb. We sum declared sizes — these are advisory
|
||||
# (an attacker can lie) but mismatched values trip Python's own
|
||||
# CRC/size check during read. The pre-extract sum catches the
|
||||
# honest-malicious case (large declared sizes) and is the last
|
||||
# cheap fence before extractall touches the disk.
|
||||
total_uncompressed += int(member.file_size or 0)
|
||||
if total_uncompressed > MAX_ZIP_UNCOMPRESSED:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"zip_too_large_uncompressed (max {MAX_ZIP_UNCOMPRESSED // 1024 // 1024}MB)",
|
||||
)
|
||||
|
||||
zf.extractall(dest)
|
||||
|
||||
|
||||
|
|
@ -729,6 +813,8 @@ async def create_entity(
|
|||
if category and not is_valid_category(category):
|
||||
raise HTTPException(status_code=400, detail="invalid_category")
|
||||
|
||||
video_url = _validate_video_url(video_url)
|
||||
|
||||
# Stream + extract ZIP into a scratch dir.
|
||||
tmp, size = await _stream_to_temp(file, MAX_ZIP_SIZE, suffix=".zip")
|
||||
try:
|
||||
|
|
@ -757,6 +843,14 @@ async def create_entity(
|
|||
|
||||
entity_id = uuid.uuid4().hex
|
||||
suffixed = suffixed_name(final_name, username)
|
||||
# Global cross-owner check — sanitize_username is many-to-one, so
|
||||
# two emails (alice.smith / alice_smith) can resolve to the same
|
||||
# username and produce the same `<name>-by-<username>` suffix even
|
||||
# when the per-owner UNIQUE passes. The suffixed value drives both
|
||||
# the bundle on-disk dir and the served plugin.json `name`, so a
|
||||
# collision silently last-write-wins. Refuse upfront.
|
||||
if _suffixed_already_taken(conn, suffixed):
|
||||
raise HTTPException(status_code=409, detail="conflict_global_suffix")
|
||||
plugin_dir = _plugin_dir(entity_id)
|
||||
file_size = _bake_plugin_tree(
|
||||
type_=type,
|
||||
|
|
@ -820,12 +914,14 @@ async def update_entity(
|
|||
entity = repo.get(entity_id)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail="entity_not_found")
|
||||
if entity["owner_user_id"] != user["id"]:
|
||||
if entity["owner_user_id"] != user["id"] and not is_user_admin(user["id"], conn):
|
||||
raise HTTPException(status_code=403, detail="not_owner")
|
||||
|
||||
if category and not is_valid_category(category):
|
||||
raise HTTPException(status_code=400, detail="invalid_category")
|
||||
|
||||
video_url = _validate_video_url(video_url)
|
||||
|
||||
new_version: Optional[str] = None
|
||||
new_size: Optional[int] = None
|
||||
if file is not None:
|
||||
|
|
|
|||
|
|
@ -869,6 +869,7 @@ async def store_detail(
|
|||
from src.repositories.user_store_installs import UserStoreInstallsRepository
|
||||
from src.store_naming import suffixed_name
|
||||
from app.utils import get_store_dir
|
||||
from app.auth.access import is_user_admin
|
||||
|
||||
entity = StoreEntitiesRepository(conn).get(entity_id)
|
||||
if not entity:
|
||||
|
|
@ -897,6 +898,10 @@ async def store_detail(
|
|||
user["id"], entity_id
|
||||
)
|
||||
is_owner = entity["owner_user_id"] == user["id"]
|
||||
# Admin can also Edit/Delete (parity with the API: store.py guards both
|
||||
# mutations on owner OR admin). Without this the store_detail buttons
|
||||
# would be hidden from admin even though they have authority.
|
||||
is_admin = is_user_admin(user["id"], conn)
|
||||
|
||||
ctx = _build_context(
|
||||
request,
|
||||
|
|
@ -907,6 +912,7 @@ async def store_detail(
|
|||
files=files,
|
||||
is_installed=is_installed,
|
||||
is_owner=is_owner,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
return templates.TemplateResponse(request, "store_detail.html", ctx)
|
||||
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@
|
|||
<div class="breadcrumb"><a href="/store">Store</a> / {{ entity.name }}</div>
|
||||
|
||||
<div class="detail-hero">
|
||||
{% if is_owner %}
|
||||
{% if is_owner or is_admin %}
|
||||
<div class="owner-actions">
|
||||
<a href="#" id="edit-btn">Edit (coming soon)</a>
|
||||
<button class="delete" id="delete-btn">Delete</button>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,16 @@ def sanitize_username(email: str) -> str:
|
|||
|
||||
Raises ``ValueError`` if the local-part sanitizes to an empty string —
|
||||
callers (the upload endpoint) translate that to a 400.
|
||||
|
||||
Note: this mapping is **many-to-one** — ``alice.smith@x`` and
|
||||
``alice_smith@x`` both yield ``alice-smith``. The Store namespace is
|
||||
flat in Claude Code, so two such users uploading entities with the
|
||||
same display name would produce identical ``<name>-by-<username>``
|
||||
suffixes and collide in the served marketplace + bundle. The upload
|
||||
endpoint enforces global uniqueness on the suffixed value via
|
||||
``app.api.store._suffixed_already_taken`` and rejects the second one
|
||||
with 409 ``conflict_global_suffix``; the per-owner UNIQUE on
|
||||
``store_entities(owner_user_id, name)`` alone does not catch this.
|
||||
"""
|
||||
local = email.split("@", 1)[0].lower()
|
||||
s = _SANITIZE_RE.sub("-", local)
|
||||
|
|
|
|||
|
|
@ -377,6 +377,231 @@ class TestStoreUpload:
|
|||
assert r.json()["detail"] in {"zip_looks_like_plugin", "zip_looks_like_skill"}
|
||||
|
||||
|
||||
class TestStoreSecurityFixes:
|
||||
"""Regression tests for the three security blockers and one correctness
|
||||
bug found in PR #180 review (F1, F2, F4, F5)."""
|
||||
|
||||
def test_video_url_javascript_scheme_rejected_on_create(self, web_client):
|
||||
"""F1 — `javascript:` URI must not be stored. Otherwise a malicious
|
||||
uploader can pop XSS in any viewer's session via the
|
||||
store_detail "Watch video" link."""
|
||||
_, cookies = _create_user(web_client, "f1a@x.com")
|
||||
r = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", _make_skill_zip("vid1"), "application/zip")},
|
||||
data={"type": "skill", "video_url": "javascript:alert(1)"},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
assert r.json()["detail"] == "invalid_video_url"
|
||||
|
||||
def test_video_url_data_scheme_rejected(self, web_client):
|
||||
_, cookies = _create_user(web_client, "f1b@x.com")
|
||||
r = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", _make_skill_zip("vid2"), "application/zip")},
|
||||
data={"type": "skill", "video_url": "data:text/html,<script>alert(1)</script>"},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == "invalid_video_url"
|
||||
|
||||
def test_video_url_https_accepted(self, web_client):
|
||||
_, cookies = _create_user(web_client, "f1c@x.com")
|
||||
r = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", _make_skill_zip("vid3"), "application/zip")},
|
||||
data={"type": "skill", "video_url": "https://www.youtube.com/watch?v=abc"},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["video_url"] == "https://www.youtube.com/watch?v=abc"
|
||||
|
||||
def test_video_url_javascript_scheme_rejected_on_update(self, web_client):
|
||||
_, cookies = _create_user(web_client, "f1d@x.com")
|
||||
c = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", _make_skill_zip("vid4"), "application/zip")},
|
||||
data={"type": "skill"}, cookies=cookies,
|
||||
)
|
||||
eid = c.json()["id"]
|
||||
u = web_client.put(
|
||||
f"/api/store/entities/{eid}",
|
||||
data={"video_url": "javascript:alert(1)"},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert u.status_code == 400
|
||||
assert u.json()["detail"] == "invalid_video_url"
|
||||
|
||||
def test_zip_bomb_uncompressed_size_rejected(self, tmp_path):
|
||||
"""F2 — _safe_zip_extract must refuse when the sum of declared
|
||||
file_size across infolist() exceeds MAX_ZIP_UNCOMPRESSED, BEFORE
|
||||
extractall touches disk.
|
||||
|
||||
We test the helper directly because Python's ``ZipFile.writestr``
|
||||
rewrites ``ZipInfo.file_size`` to the real payload length, making
|
||||
an end-to-end ZIP-with-fake-size impossible without manual header
|
||||
surgery. The bomb defense is in ``_safe_zip_extract``, so target
|
||||
it directly with a stub ZipFile whose ``infolist()`` returns
|
||||
entries with inflated declared sizes.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api import store as store_module
|
||||
|
||||
class _FakeZipFile:
|
||||
def __init__(self, infolist):
|
||||
self._infolist = infolist
|
||||
self.extracted = False
|
||||
|
||||
def infolist(self):
|
||||
return self._infolist
|
||||
|
||||
def extractall(self, dest):
|
||||
# Must not be reached — the guard is supposed to raise
|
||||
# before extractall. Mark and let the caller assert.
|
||||
self.extracted = True
|
||||
|
||||
zi = zipfile.ZipInfo("code-review/SKILL.md")
|
||||
zi.file_size = store_module.MAX_ZIP_UNCOMPRESSED + 1
|
||||
zf = _FakeZipFile([zi])
|
||||
|
||||
try:
|
||||
store_module._safe_zip_extract(zf, tmp_path)
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 413
|
||||
assert "zip_too_large_uncompressed" in str(exc.detail)
|
||||
else:
|
||||
raise AssertionError("expected HTTPException 413, got none")
|
||||
assert zf.extracted is False, "guard fired AFTER extractall — bug in fix"
|
||||
|
||||
def test_admin_can_update_non_owned_entity(self, web_client):
|
||||
"""F4 — UPDATE must permit owner OR admin (parity with DELETE)."""
|
||||
from argon2 import PasswordHasher
|
||||
from src.db import get_system_db
|
||||
from src.repositories.users import UserRepository
|
||||
from tests.helpers.auth import grant_admin
|
||||
|
||||
owner_id, owner_cookies = _create_user(web_client, "owner-f4@x.com")
|
||||
c = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", _make_skill_zip("f4-skill"), "application/zip")},
|
||||
data={"type": "skill"}, cookies=owner_cookies,
|
||||
)
|
||||
eid = c.json()["id"]
|
||||
|
||||
ph = PasswordHasher()
|
||||
conn = get_system_db()
|
||||
UserRepository(conn).create(
|
||||
id="adm-f4", email="adm-f4@x.com", name="adm",
|
||||
password_hash=ph.hash("AdminPass1!"),
|
||||
)
|
||||
grant_admin(conn, "adm-f4")
|
||||
admin_token = web_client.post(
|
||||
"/auth/token", json={"email": "adm-f4@x.com", "password": "AdminPass1!"}
|
||||
).json()["access_token"]
|
||||
admin_cookies = {"access_token": admin_token}
|
||||
|
||||
u = web_client.put(
|
||||
f"/api/store/entities/{eid}",
|
||||
data={"description": "moderated by admin"},
|
||||
cookies=admin_cookies,
|
||||
)
|
||||
assert u.status_code == 200, u.text
|
||||
assert u.json()["description"] == "moderated by admin"
|
||||
|
||||
def test_non_owner_non_admin_cannot_update(self, web_client):
|
||||
"""F4 negative — random user still gets 403 on UPDATE."""
|
||||
_, owner_cookies = _create_user(web_client, "owner-f4b@x.com")
|
||||
c = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", _make_skill_zip("f4b-skill"), "application/zip")},
|
||||
data={"type": "skill"}, cookies=owner_cookies,
|
||||
)
|
||||
eid = c.json()["id"]
|
||||
_, intruder_cookies = _create_user(web_client, "intruder-f4@x.com")
|
||||
u = web_client.put(
|
||||
f"/api/store/entities/{eid}",
|
||||
data={"description": "hijack"},
|
||||
cookies=intruder_cookies,
|
||||
)
|
||||
assert u.status_code == 403
|
||||
assert u.json()["detail"] == "not_owner"
|
||||
|
||||
def test_admin_sees_action_buttons_on_store_detail(self, web_client):
|
||||
"""F4 — admin must see Edit/Delete in store_detail UI even when
|
||||
not the owner."""
|
||||
from argon2 import PasswordHasher
|
||||
from src.db import get_system_db
|
||||
from src.repositories.users import UserRepository
|
||||
from tests.helpers.auth import grant_admin
|
||||
|
||||
_, owner_cookies = _create_user(web_client, "owner-ui@x.com")
|
||||
c = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", _make_skill_zip("ui-skill"), "application/zip")},
|
||||
data={"type": "skill"}, cookies=owner_cookies,
|
||||
)
|
||||
eid = c.json()["id"]
|
||||
|
||||
ph = PasswordHasher()
|
||||
conn = get_system_db()
|
||||
UserRepository(conn).create(
|
||||
id="adm-ui", email="adm-ui@x.com", name="adm",
|
||||
password_hash=ph.hash("AdminPass1!"),
|
||||
)
|
||||
grant_admin(conn, "adm-ui")
|
||||
admin_token = web_client.post(
|
||||
"/auth/token", json={"email": "adm-ui@x.com", "password": "AdminPass1!"}
|
||||
).json()["access_token"]
|
||||
admin_cookies = {"access_token": admin_token}
|
||||
|
||||
page = web_client.get(f"/store/{eid}", cookies=admin_cookies)
|
||||
assert page.status_code == 200, page.text
|
||||
# Admin-non-owner sees the owner-actions panel — pre-fix, the
|
||||
# `is_owner` gate hid it. Same gate now reads `is_owner or
|
||||
# is_admin`.
|
||||
assert "owner-actions" in page.text
|
||||
assert 'id="delete-btn"' in page.text
|
||||
|
||||
def test_cross_owner_suffix_collision_rejected(self, web_client):
|
||||
"""F5 — two emails can sanitize to the same username
|
||||
(alice.smith / alice_smith → alice-smith). Both uploading a skill
|
||||
called `code-review` would yield the same `code-review-by-alice-smith`
|
||||
and silently collide in the served bundle + manifest. The upload
|
||||
endpoint must refuse the second one."""
|
||||
_, a_cookies = _create_user(web_client, "alice.smith@x.com")
|
||||
r1 = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", _make_skill_zip("collide"), "application/zip")},
|
||||
data={"type": "skill"}, cookies=a_cookies,
|
||||
)
|
||||
assert r1.status_code == 201, r1.text
|
||||
assert r1.json()["invocation_name"] == "collide-by-alice-smith"
|
||||
|
||||
_, b_cookies = _create_user(web_client, "alice_smith@x.com")
|
||||
r2 = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", _make_skill_zip("collide"), "application/zip")},
|
||||
data={"type": "skill"}, cookies=b_cookies,
|
||||
)
|
||||
assert r2.status_code == 409, r2.text
|
||||
assert r2.json()["detail"] == "conflict_global_suffix"
|
||||
|
||||
def test_distinct_suffixes_pass(self, web_client):
|
||||
"""F5 — uploads that yield distinct suffixed names must pass. (Avoid
|
||||
regressing into rejecting all distinct uploads.)"""
|
||||
_, a_cookies = _create_user(web_client, "alice@x.com")
|
||||
_, b_cookies = _create_user(web_client, "bob@x.com")
|
||||
for cookies, skill_name in [(a_cookies, "alpha"), (b_cookies, "beta")]:
|
||||
r = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", _make_skill_zip(skill_name), "application/zip")},
|
||||
data={"type": "skill"}, cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
|
||||
class TestInstallCycle:
|
||||
def test_install_uninstall_and_count(self, web_client):
|
||||
# Owner uploads, two other users install, install_count = 2.
|
||||
|
|
|
|||
Loading…
Reference in a new issue