kink-discovery / tests /test_admin_endpoints.py
Perplexed7675's picture
Sync from kink_cli (Docker Space)
9ca78b1 verified
Raw
History Blame Contribute Delete
16.6 kB
"""Tests for the /admin/* surface in api.py.
These endpoints power the deploy/restore CLI (scripts/admin_deploy.py) and the
help-a-stuck-user runbook (regenerate-token, scrub-plays, force-link, etc.). A careless
edit to /admin/import would clobber every profile silently β€” this file is the regression
gate that catches it.
Pattern: in-memory SQLite per test (copies the bundled seed), fresh `api` module reload,
TestClient. Mirrors `tests/test_auth_login.py:14-27`.
"""
from __future__ import annotations
import io
import shutil
import sqlite3
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
_SEED = Path(__file__).resolve().parent.parent / "deploy/hf/seed/hf_bundled_store.db"
_ADMIN_SECRET = "test-secret-12345"
@pytest.fixture()
def admin_api(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Returns (api_module, client, store_path) with admin auth wired up."""
assert _SEED.is_file(), f"Missing seed DB: {_SEED}"
dest = tmp_path / "admin_test_store.db"
shutil.copyfile(_SEED, dest)
monkeypatch.setenv("KINK_STORE_PATH", str(dest))
monkeypatch.setenv("KINK_SKIP_HEAVY_WARM", "1")
monkeypatch.setenv("KINK_HF_REQUIRE_FULL_CATALOG", "0")
monkeypatch.setenv("KINK_ADMIN_SECRET", _ADMIN_SECRET)
sys.modules.pop("api", None)
import api as api_mod
api_mod._backend_impl = None
api_mod.ADMIN_SECRET = _ADMIN_SECRET # picks up new env via re-import, but be explicit
api_mod._get_backend()
client = TestClient(api_mod.app)
return api_mod, client, dest
def _hdr(secret: str = _ADMIN_SECRET) -> dict:
return {"x-admin-secret": secret}
def _make_user(client: TestClient) -> dict:
return client.post("/users", json={}).json()
def _save_play(client: TestClient, user: dict, kink_id: str, rating: str = "love",
directions: list[str] | None = None) -> None:
r = client.post(
f"/users/{user['id']}/plays",
json={"kink_id": kink_id, "interest_state": rating, "directions": directions or ["together"]},
headers={"x-private-token": user["private_token"]},
)
assert r.status_code in (200, 201), r.text
# ─────────────────────────────────────────────────────────────────────────────
# Phase 1.4 β€” auth gate sweep
# ─────────────────────────────────────────────────────────────────────────────
def test_every_admin_route_rejects_missing_secret(admin_api) -> None:
"""If a future endpoint forgets ``require_admin``, this test fails. Sweeps the actual
FastAPI route table β€” no hand-maintained list to drift."""
api_mod, client, _ = admin_api
paths_methods: list[tuple[str, str]] = []
for route in api_mod.app.routes:
path = getattr(route, "path", "")
if not path.startswith("/admin/"):
continue
methods = (getattr(route, "methods", set()) or set()) - {"HEAD", "OPTIONS"}
for m in methods:
paths_methods.append((m, path))
assert paths_methods, "no admin routes discovered β€” registration broken?"
# Concrete-path substitutions for routes with placeholders so we hit handler not 404.
substitutions = {
"{user_id}": "real-bunny-72",
"{kink_id}": "demo_kink_5",
"{partner_id}": "exact-wahoo-46",
"{user_a}": "real-bunny-72",
"{user_b}": "exact-wahoo-46",
}
for method, path in paths_methods:
concrete = path
for k, v in substitutions.items():
concrete = concrete.replace(k, v)
r = client.request(method, concrete)
assert r.status_code == 403, f"{method} {concrete} returned {r.status_code} (expected 403)"
def test_admin_rejects_wrong_secret(admin_api) -> None:
_, client, _ = admin_api
r = client.get("/admin/users", headers={"x-admin-secret": "not-the-real-secret"})
assert r.status_code == 403
# ─────────────────────────────────────────────────────────────────────────────
# Phase 1.2 β€” coverage matrix
# ─────────────────────────────────────────────────────────────────────────────
def test_list_users_returns_counts(admin_api) -> None:
_, client, _ = admin_api
u1 = _make_user(client)
u2 = _make_user(client)
_save_play(client, u1, "demo_kink_3")
_save_play(client, u1, "demo_kink_5")
_save_play(client, u2, "demo_kink_1")
r = client.get("/admin/users", headers=_hdr())
assert r.status_code == 200, r.text
body = r.json()
by_id = {it["user_id"]: it for it in body["items"]}
assert by_id[u1["id"]]["plays"] == 2
assert by_id[u2["id"]]["plays"] == 1
assert body["total"] >= 2
def test_get_user_includes_private_token(admin_api) -> None:
_, client, _ = admin_api
u = _make_user(client)
r = client.get(f"/admin/users/{u['id']}", headers=_hdr())
assert r.status_code == 200
body = r.json()
assert body["id"] == u["id"]
assert body["private_token"] == u["private_token"]
def test_get_user_404_for_unknown(admin_api) -> None:
_, client, _ = admin_api
r = client.get("/admin/users/no-such-user", headers=_hdr())
assert r.status_code == 404
def test_regenerate_token_invalidates_old_login(admin_api) -> None:
_, client, _ = admin_api
u = _make_user(client)
old_token = u["private_token"]
r = client.post(f"/admin/users/{u['id']}/regenerate-token", headers=_hdr())
assert r.status_code == 200
new_token = r.json()["private_token"]
assert new_token != old_token
# Old token rejected on /auth/login
bad = client.post("/auth/login", json={"user_id": u["id"], "private_token": old_token})
assert bad.status_code == 401
# New token accepted
good = client.post("/auth/login", json={"user_id": u["id"], "private_token": new_token})
assert good.status_code == 200
def test_admin_delete_user_cascades(admin_api) -> None:
_, client, _ = admin_api
u = _make_user(client)
_save_play(client, u, "demo_kink_3")
r = client.delete(f"/admin/users/{u['id']}", headers=_hdr())
assert r.status_code == 200
# Subsequent /admin/users/{uid} β†’ 404
r2 = client.get(f"/admin/users/{u['id']}", headers=_hdr())
assert r2.status_code == 404
def test_admin_delete_user_idempotent(admin_api) -> None:
_, client, _ = admin_api
r = client.delete("/admin/users/never-existed", headers=_hdr())
assert r.status_code == 200 # no-op is fine
def test_scrub_plays_keeps_profile(admin_api) -> None:
_, client, _ = admin_api
u = _make_user(client)
_save_play(client, u, "demo_kink_3")
_save_play(client, u, "demo_kink_5")
r = client.post(f"/admin/users/{u['id']}/scrub-plays", headers=_hdr())
assert r.status_code == 200
body = r.json()
assert body["deleted"] == 2
full = client.get(f"/admin/users/{u['id']}", headers=_hdr()).json()
assert full["id"] == u["id"]
assert full["plays"] == {}
def test_admin_delete_one_play(admin_api) -> None:
_, client, _ = admin_api
u = _make_user(client)
_save_play(client, u, "demo_kink_3")
_save_play(client, u, "demo_kink_5")
r = client.delete(f"/admin/users/{u['id']}/plays/demo_kink_3", headers=_hdr())
assert r.status_code == 200
full = client.get(f"/admin/users/{u['id']}", headers=_hdr()).json()
assert "demo_kink_3" not in full["plays"]
assert "demo_kink_5" in full["plays"]
def test_force_link_creates_partner_link(admin_api) -> None:
_, client, _ = admin_api
a = _make_user(client)
b = _make_user(client)
r = client.post(f"/admin/users/{a['id']}/partners/{b['id']}/force-link", headers=_hdr())
assert r.status_code == 200
body = r.json()
assert body["created"] is True
a_full = client.get(f"/admin/users/{a['id']}", headers=_hdr()).json()
assert b["id"] in a_full["partners"]
def test_force_link_idempotent(admin_api) -> None:
_, client, _ = admin_api
a = _make_user(client)
b = _make_user(client)
client.post(f"/admin/users/{a['id']}/partners/{b['id']}/force-link", headers=_hdr())
r = client.post(f"/admin/users/{a['id']}/partners/{b['id']}/force-link", headers=_hdr())
assert r.status_code == 200
assert r.json()["created"] is False # already exists
def test_force_link_rejects_self(admin_api) -> None:
_, client, _ = admin_api
u = _make_user(client)
r = client.post(f"/admin/users/{u['id']}/partners/{u['id']}/force-link", headers=_hdr())
assert r.status_code == 400
def test_unlink_drops_partner_link(admin_api) -> None:
_, client, _ = admin_api
a = _make_user(client)
b = _make_user(client)
client.post(f"/admin/users/{a['id']}/partners/{b['id']}/force-link", headers=_hdr())
r = client.delete(f"/admin/partners/{a['id']}/{b['id']}", headers=_hdr())
assert r.status_code == 200
assert r.json()["removed"] is True
a_full = client.get(f"/admin/users/{a['id']}", headers=_hdr()).json()
assert b["id"] not in a_full["partners"]
def test_unlink_idempotent_when_no_link(admin_api) -> None:
_, client, _ = admin_api
a = _make_user(client)
b = _make_user(client)
r = client.delete(f"/admin/partners/{a['id']}/{b['id']}", headers=_hdr())
assert r.status_code == 200
assert r.json()["removed"] is False
def test_export_returns_user_state_db(admin_api, tmp_path: Path) -> None:
_, client, _ = admin_api
u = _make_user(client)
_save_play(client, u, "demo_kink_3")
r = client.get("/admin/export", headers=_hdr())
assert r.status_code == 200
assert r.headers["content-type"] == "application/octet-stream"
body = r.content
assert len(body) > 0
# Round-trip: write to a fresh fresh sqlite, confirm USER_TABLES present.
out = tmp_path / "exported.db"
out.write_bytes(body)
conn = sqlite3.connect(f"file:{out}?mode=ro", uri=True)
try:
users = conn.execute("SELECT id FROM user").fetchall()
assert any(row[0] == u["id"] for row in users)
plays = conn.execute(
'SELECT user_id, kink_id FROM playpreference WHERE user_id = ?', (u["id"],)
).fetchall()
assert ("demo_kink_3" in {p[1] for p in plays})
finally:
conn.close()
def test_import_applies_user_state(admin_api, tmp_path: Path) -> None:
api_mod, client, store_path = admin_api
# Create a known user, export it, then delete the user, then import β€” verify restored.
u = _make_user(client)
_save_play(client, u, "demo_kink_3")
export_bytes = client.get("/admin/export", headers=_hdr()).content
client.delete(f"/admin/users/{u['id']}", headers=_hdr())
# User is gone now
assert client.get(f"/admin/users/{u['id']}", headers=_hdr()).status_code == 404
# Import the snapshot back
r = client.post(
"/admin/import",
content=export_bytes,
headers={**_hdr(), "Content-Type": "application/octet-stream"},
)
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["total_rows"] >= 2 # at minimum: 1 user + 1 play
# User is back
restored = client.get(f"/admin/users/{u['id']}", headers=_hdr()).json()
assert restored["id"] == u["id"]
assert "demo_kink_3" in restored["plays"]
def test_import_rejects_empty_body(admin_api) -> None:
_, client, _ = admin_api
r = client.post(
"/admin/import",
content=b"",
headers={**_hdr(), "Content-Type": "application/octet-stream"},
)
assert r.status_code == 400
def test_stats_reflects_live_data(admin_api) -> None:
_, client, _ = admin_api
u1 = _make_user(client)
u2 = _make_user(client)
_save_play(client, u1, "demo_kink_3")
_save_play(client, u1, "demo_kink_5")
_save_play(client, u2, "demo_kink_3")
r = client.get("/admin/stats", headers=_hdr())
assert r.status_code == 200
body = r.json()
assert body["row_counts"]["user"] >= 2
assert body["row_counts"]["playpreference"] >= 3
# top_kinks: demo_kink_3 has 2 saves, demo_kink_5 has 1 β†’ 247 ranks first
if body["top_kinks"]:
assert body["top_kinks"][0]["save_count"] >= body["top_kinks"][-1]["save_count"]
def test_admin_health_payload_shape(admin_api) -> None:
_, client, _ = admin_api
r = client.get("/admin/health", headers=_hdr())
assert r.status_code == 200
body = r.json()
assert "ok" in body
assert "store_path" in body
snap = body["snapshot"]
for key in ("enabled", "repo", "filename", "has_hf_token", "last_restore"):
assert key in snap, f"snapshot missing {key}"
# ─────────────────────────────────────────────────────────────────────────────
# Phase 1.3 β€” snapshot push/pull (Hub-bound β€” mocked)
# ─────────────────────────────────────────────────────────────────────────────
def test_snapshot_push_disabled_returns_409(admin_api) -> None:
_, client, _ = admin_api
with patch("backend.user_snapshot_hub.snapshot_enabled", return_value=False):
r = client.post("/admin/snapshot/push", headers=_hdr())
assert r.status_code == 409
def test_snapshot_push_skips_empty_store(admin_api, tmp_path: Path, monkeypatch) -> None:
"""When the local store has no users, refuse to overwrite the Hub copy with empty bytes."""
# Wipe all users from the seeded store first
api_mod, client, store_path = admin_api
body_users = client.get("/admin/users", headers=_hdr()).json()
for it in body_users["items"]:
client.delete(f"/admin/users/{it['user_id']}", headers=_hdr())
with patch("backend.user_snapshot_hub.snapshot_enabled", return_value=True), \
patch("backend.user_snapshot_hub.push_user_snapshot") as push_mock:
r = client.post("/admin/snapshot/push", headers=_hdr())
assert r.status_code == 200
body = r.json()
assert body["ok"] is False
assert "skipped" in body
push_mock.assert_not_called()
def test_snapshot_push_calls_hub_push(admin_api) -> None:
_, client, _ = admin_api
_make_user(client) # ensure non-empty
with patch("backend.user_snapshot_hub.snapshot_enabled", return_value=True), \
patch("backend.user_snapshot_hub.push_user_snapshot", return_value=True) as push_mock:
r = client.post("/admin/snapshot/push", headers=_hdr())
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
push_mock.assert_called_once()
def test_snapshot_pull_disabled_returns_409(admin_api) -> None:
_, client, _ = admin_api
with patch("backend.user_snapshot_hub.snapshot_enabled", return_value=False):
r = client.post("/admin/snapshot/pull", headers=_hdr())
assert r.status_code == 409
def test_snapshot_pull_calls_hub_pull(admin_api, tmp_path: Path) -> None:
"""Mock pull_user_snapshot to write a small valid snapshot to the target path, then verify
restore_user_state runs and refresh_cache is invoked."""
_, client, _ = admin_api
# Pre-seed a user, export bytes, then have the mocked pull_user_snapshot drop those bytes
# at the requested target_path so restore_user_state has something legitimate to apply.
u = _make_user(client)
snapshot_bytes = client.get("/admin/export", headers=_hdr()).content
def fake_pull(target_path):
Path(target_path).write_bytes(snapshot_bytes)
return True
with patch("backend.user_snapshot_hub.snapshot_enabled", return_value=True), \
patch("backend.user_snapshot_hub.pull_user_snapshot", side_effect=fake_pull) as pull_mock:
r = client.post("/admin/snapshot/pull", headers=_hdr())
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["total_rows"] >= 1
pull_mock.assert_called_once()