df6d89cb59
Operator-flagged 2026-05-27: walk the whole project for the same shape
as the min-dim Delete-button silent failure (crypto.subtle TypeError
on plain HTTP). FC runs over plain HTTP per the homelab posture;
Secure-Context-gated browser APIs are undefined on the production
origin.
**Audit results across `frontend/src/`:**
crypto.subtle.digest — 2 sites:
- MinDimensionCard (fixed 2026-05-27)
- BulkEditorPanel (THIS FIX)
navigator.clipboard — 1 site, already guarded:
- utils/clipboard.js writeText with execCommand fallback
serviceWorker / mediaDevices / Push / Web USB|HID|Bluetooth|Serial /
cookieStore / queryLocalFonts / WebAuthn / geolocation
— NOT USED, nothing to fix
Extension scripts (background.js) use crypto.subtle but run from
moz-extension:// which IS a Secure Context — left as-is.
**BulkEditorPanel double bug**
The bulk-delete UI on the gallery selection had been broken since
FC-3k shipped, in two ways:
1. `crypto.subtle.digest` swallowed TypeError on plain HTTP — modal
never opened. Same symptom as min-dim.
2. Even on HTTPS, the modal's `kind="images-selection"` produced
`delete-images-selection-<sha8>` while the backend expected
`delete-images-<sha8>`. The two would never match.
Fix:
- Backend `/api/admin/images/bulk-delete` dry-run response now returns
`confirm_token` (the canonical `delete-images-<sha8>` string).
Integration test `test_bulk_delete_dry_run_returns_counts` pinned to
assert the new field.
- DestructiveConfirmModal gains an `expectedTokenOverride` prop. When
set, it bypasses the `${action}-${kind}-${runId}` formula and uses
the explicit string. This decouples the UI label (`kind`) from the
wire-format token (server-provided), so future endpoints can use a
kind-specific label without their kind name leaking into the token.
- BulkEditorPanel passes `:expected-token-override="bulkProjected?.confirm_token"`
— no client-side crypto, no kind-prefix mismatch.
- MinDimensionCard refactored to the same explicit pattern (was
slicing the 8-char suffix off the backend's token and passing it
through `runId`; now passes the full backend token via
`expected-token-override` directly). Cleaner; one source of truth.
**Banked memory**
`feedback_no_secure_context_apis.md` documents the full table of
Secure-Context-gated APIs, which ones FC currently uses, and how each
is handled. Indexed in MEMORY.md. Sites for the audit also listed in
the memory for future drift-checking.
No other Secure-Context-gated APIs found in `frontend/src/`. The same
shape won't recur unless someone adds a new dependency on one — at
which point the banked memory should fire.
367 lines
9.9 KiB
Python
367 lines
9.9 KiB
Python
"""FC-3k: /api/admin/* endpoint integration tests.
|
|
|
|
Monkeypatch `.delay()` for Tier-C tasks to capture dispatch payloads
|
|
without actually queuing. Tier-B/A endpoints run synchronously
|
|
through the real service.
|
|
"""
|
|
import hashlib
|
|
|
|
import pytest
|
|
|
|
from backend.app import create_app
|
|
from backend.app.models import Artist, ImageRecord, Tag, TagKind
|
|
from backend.app.models.tag import image_tag
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
@pytest.fixture
|
|
async def app():
|
|
return create_app()
|
|
|
|
|
|
@pytest.fixture
|
|
async def client(app):
|
|
async with app.test_client() as c:
|
|
yield c
|
|
|
|
|
|
# --- Tier-C: POST /artists/<slug>/cascade-delete --------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_artist_cascade_dry_run_returns_projection(client, db):
|
|
a = Artist(name="Aria", slug="aria")
|
|
db.add(a)
|
|
await db.commit()
|
|
|
|
resp = await client.post(
|
|
"/api/admin/artists/aria/cascade-delete",
|
|
json={"dry_run": True},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["artist"]["slug"] == "aria"
|
|
assert "projected" in body
|
|
assert body["projected"]["images"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_artist_cascade_unknown_slug_404(client):
|
|
resp = await client.post(
|
|
"/api/admin/artists/no-such/cascade-delete",
|
|
json={"dry_run": True},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_artist_cascade_wrong_confirm_400(client, db):
|
|
a = Artist(name="Aria", slug="aria")
|
|
db.add(a)
|
|
await db.commit()
|
|
artist_id = a.id
|
|
|
|
resp = await client.post(
|
|
"/api/admin/artists/aria/cascade-delete",
|
|
json={"dry_run": False, "confirm": "wrong"},
|
|
)
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "confirm_mismatch"
|
|
assert body["expected"] == f"delete-artist-{artist_id}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_artist_cascade_correct_confirm_dispatches(
|
|
client, db, monkeypatch,
|
|
):
|
|
a = Artist(name="Aria", slug="aria")
|
|
db.add(a)
|
|
await db.commit()
|
|
artist_id = a.id
|
|
|
|
dispatched = []
|
|
|
|
class _Result:
|
|
id = "fake-task-id"
|
|
|
|
def _delay(**kw):
|
|
dispatched.append(kw)
|
|
return _Result()
|
|
|
|
monkeypatch.setattr(
|
|
"backend.app.tasks.admin.delete_artist_cascade_task.delay", _delay,
|
|
)
|
|
|
|
resp = await client.post(
|
|
"/api/admin/artists/aria/cascade-delete",
|
|
json={
|
|
"dry_run": False,
|
|
"confirm": f"delete-artist-{artist_id}",
|
|
},
|
|
)
|
|
assert resp.status_code == 202
|
|
body = await resp.get_json()
|
|
assert body["task_id"] == "fake-task-id"
|
|
assert dispatched == [{"artist_id": artist_id}]
|
|
|
|
|
|
# --- Tier-C: POST /images/bulk-delete -------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_delete_dry_run_returns_counts(client, db, tmp_path):
|
|
a = Artist(name="B", slug="b")
|
|
db.add(a)
|
|
await db.flush()
|
|
i1 = ImageRecord(
|
|
artist_id=a.id, path=str(tmp_path / "1.jpg"),
|
|
sha256="1" * 64, size_bytes=10, mime="image/jpeg",
|
|
origin="imported_filesystem",
|
|
)
|
|
i2 = ImageRecord(
|
|
artist_id=a.id, path=str(tmp_path / "2.jpg"),
|
|
sha256="2" * 64, size_bytes=20, mime="image/jpeg",
|
|
origin="imported_filesystem",
|
|
)
|
|
db.add_all([i1, i2])
|
|
await db.commit()
|
|
|
|
resp = await client.post(
|
|
"/api/admin/images/bulk-delete",
|
|
json={
|
|
"image_ids": [i1.id, i2.id, 9_999_999],
|
|
"dry_run": True,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["images_found"] == 2
|
|
assert body["bytes_on_disk"] == 30
|
|
assert body["missing_ids"] == [9_999_999]
|
|
# Dry-run hands the canonical Tier-C confirm token back so the
|
|
# frontend doesn't recompute SHA-256 client-side (crypto.subtle
|
|
# is Secure-Context-gated; FC runs over plain HTTP).
|
|
assert body["confirm_token"].startswith("delete-images-")
|
|
assert len(body["confirm_token"]) == len("delete-images-") + 8
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_delete_empty_list_400(client):
|
|
resp = await client.post(
|
|
"/api/admin/images/bulk-delete",
|
|
json={"image_ids": [], "dry_run": True},
|
|
)
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "invalid_image_ids"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_delete_non_int_400(client):
|
|
resp = await client.post(
|
|
"/api/admin/images/bulk-delete",
|
|
json={"image_ids": ["foo", 2], "dry_run": True},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_delete_wrong_confirm_400_with_expected_token(
|
|
client, db, tmp_path,
|
|
):
|
|
a = Artist(name="B", slug="b")
|
|
db.add(a)
|
|
await db.flush()
|
|
img = ImageRecord(
|
|
artist_id=a.id, path=str(tmp_path / "x.jpg"),
|
|
sha256="c" * 64, size_bytes=10, mime="image/jpeg",
|
|
origin="imported_filesystem",
|
|
)
|
|
db.add(img)
|
|
await db.commit()
|
|
img_id = img.id
|
|
|
|
resp = await client.post(
|
|
"/api/admin/images/bulk-delete",
|
|
json={
|
|
"image_ids": [img_id], "dry_run": False, "confirm": "nope",
|
|
},
|
|
)
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "confirm_mismatch"
|
|
assert body["expected"].startswith("delete-images-")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_delete_correct_confirm_dispatches(
|
|
client, db, tmp_path, monkeypatch,
|
|
):
|
|
a = Artist(name="B", slug="b")
|
|
db.add(a)
|
|
await db.flush()
|
|
img = ImageRecord(
|
|
artist_id=a.id, path=str(tmp_path / "x.jpg"),
|
|
sha256="d" * 64, size_bytes=10, mime="image/jpeg",
|
|
origin="imported_filesystem",
|
|
)
|
|
db.add(img)
|
|
await db.commit()
|
|
img_id = img.id
|
|
|
|
sha8 = hashlib.sha256(str(img_id).encode("utf-8")).hexdigest()[:8]
|
|
|
|
dispatched = []
|
|
|
|
class _Result:
|
|
id = "fake-bulk-id"
|
|
|
|
def _delay(**kw):
|
|
dispatched.append(kw)
|
|
return _Result()
|
|
|
|
monkeypatch.setattr(
|
|
"backend.app.tasks.admin.bulk_delete_images_task.delay", _delay,
|
|
)
|
|
|
|
resp = await client.post(
|
|
"/api/admin/images/bulk-delete",
|
|
json={
|
|
"image_ids": [img_id],
|
|
"dry_run": False,
|
|
"confirm": f"delete-images-{sha8}",
|
|
},
|
|
)
|
|
assert resp.status_code == 202
|
|
body = await resp.get_json()
|
|
assert body["task_id"] == "fake-bulk-id"
|
|
assert dispatched == [{"image_ids": [img_id]}]
|
|
|
|
|
|
# --- Tier-B: DELETE /tags/<id> + POST /tags/<dest>/merge -----------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tag_delete_200_after_cascade(client, db):
|
|
t = Tag(name="doomed-tag", kind=TagKind.general)
|
|
db.add(t)
|
|
await db.commit()
|
|
tag_id = t.id
|
|
|
|
resp = await client.delete(f"/api/admin/tags/{tag_id}")
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["deleted"]["id"] == tag_id
|
|
assert body["associations_removed"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tag_delete_404_unknown(client):
|
|
resp = await client.delete("/api/admin/tags/9999999")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tag_merge_succeeds_for_same_kind(client, db):
|
|
src = Tag(name="src-tag", kind=TagKind.general)
|
|
dest = Tag(name="dest-tag", kind=TagKind.general)
|
|
db.add_all([src, dest])
|
|
await db.commit()
|
|
src_id = src.id
|
|
dest_id = dest.id
|
|
|
|
resp = await client.post(
|
|
f"/api/admin/tags/{dest_id}/merge",
|
|
json={"source_id": src_id},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["result"]["target_id"] == dest_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tag_merge_rejects_self_merge(client, db):
|
|
t = Tag(name="x", kind=TagKind.general)
|
|
db.add(t)
|
|
await db.commit()
|
|
resp = await client.post(
|
|
f"/api/admin/tags/{t.id}/merge",
|
|
json={"source_id": t.id},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tag_merge_returns_400_on_kind_mismatch(client, db):
|
|
src = Tag(name="src", kind=TagKind.general)
|
|
dest = Tag(name="dest", kind=TagKind.artist)
|
|
db.add_all([src, dest])
|
|
await db.commit()
|
|
resp = await client.post(
|
|
f"/api/admin/tags/{dest.id}/merge",
|
|
json={"source_id": src.id},
|
|
)
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "tag_kind_mismatch"
|
|
|
|
|
|
# --- Tier-B helper: GET /tags/<id>/usage-count ----------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tag_usage_count_returns_zero_for_unused(client, db):
|
|
t = Tag(name="lonely", kind=TagKind.general)
|
|
db.add(t)
|
|
await db.commit()
|
|
resp = await client.get(f"/api/admin/tags/{t.id}/usage-count")
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["count"] == 0
|
|
|
|
|
|
# --- Tier-A: POST /tags/prune-unused --------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prune_unused_dry_run_lists_unused(client, db, tmp_path):
|
|
a = Artist(name="P", slug="p")
|
|
db.add(a)
|
|
await db.flush()
|
|
img = ImageRecord(
|
|
artist_id=a.id, path=str(tmp_path / "p.jpg"),
|
|
sha256="e" * 64, size_bytes=10, mime="image/jpeg",
|
|
origin="imported_filesystem",
|
|
)
|
|
db.add(img)
|
|
used = Tag(name="kept", kind=TagKind.general)
|
|
unused = Tag(name="prune", kind=TagKind.general)
|
|
db.add_all([used, unused])
|
|
await db.flush()
|
|
await db.execute(image_tag.insert().values(
|
|
image_record_id=img.id, tag_id=used.id,
|
|
))
|
|
await db.commit()
|
|
|
|
resp = await client.post(
|
|
"/api/admin/tags/prune-unused", json={"dry_run": True},
|
|
)
|
|
body = await resp.get_json()
|
|
assert body["count"] == 1
|
|
assert "prune" in body["sample_names"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prune_unused_commit_deletes_and_returns_count(client, db):
|
|
db.add(Tag(name="byebye", kind=TagKind.general))
|
|
await db.commit()
|
|
|
|
resp = await client.post(
|
|
"/api/admin/tags/prune-unused", json={"dry_run": False},
|
|
)
|
|
body = await resp.get_json()
|
|
assert body["deleted"] >= 1
|
|
assert "byebye" in body["sample_names"]
|