multi-user auth (CLI-created accounts), per-user history, test suite
- server/users.py replaces credentials.py: users.json (0600), PBKDF2, one-time migration of the legacy single password to user admin (history.json -> history/admin.json) - accounts are CLI-managed only: python -m server.users add|rm|list (no self-signup on an internet-exposed app); /signup route removed, login page back to username + password - per-user 30-day signed session cookie (payload carries the username), per-user lockout, GET /api/me, POST /account/delete - per-user watch history: server/data/history/<user>.json; ANIWEB_DATA env override for the data dir - header chip: logged-in user, logout, delete account - tests/: throwaway-server suite (port 8399, temp data dir) — 9 HTTP tests + 2 Playwright browser tests (login UI, real video playback behind auth); runs via python tests/run.py
This commit is contained in:
1 parent
76c45a8f29
commit
c697500c7d
12 files changed
+595
-122
No files matched your search
@@ -0,0 +1,86 @@
|
||||
"""run.py — full ani-web test suite.
|
||||
|
||||
Spins up a throwaway server (127.0.0.1:8399, temp data dir, seeded
|
||||
users admin/alice/bob), runs the API tests and (if playwright is
|
||||
installed) the browser tests, then tears everything down. The real
|
||||
server on :8321 and its data are never touched.
|
||||
|
||||
Usage: .venv/bin/python tests/run.py
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
PORT = 8399
|
||||
BASE = f"http://127.0.0.1:{PORT}"
|
||||
USERS = (("admin", "adminpw12345"), ("alice", "alicepw12345"), ("bob", "bobpw12345"),
|
||||
("dave", "davepw12345"))
|
||||
|
||||
|
||||
def _wait_ready():
|
||||
for _ in range(120):
|
||||
try:
|
||||
urllib.request.urlopen(BASE + "/login", timeout=1)
|
||||
return
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
raise SystemExit("server did not start")
|
||||
|
||||
|
||||
def main():
|
||||
data = Path(tempfile.mkdtemp(prefix="aniweb-test-"))
|
||||
os.environ["ANIWEB_DATA"] = str(data)
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from server import users
|
||||
for name, pw in USERS:
|
||||
users.add(name, pw)
|
||||
(data / "history").mkdir()
|
||||
(data / "history" / "admin.json").write_text(json.dumps([
|
||||
{"title": "Seed Anime", "slug_id": "seed-1", "season_id": 1, "ep_id": "1",
|
||||
"lang": "eng", "ep": "1", "pos": 10.0, "t": time.time()}]))
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "server.app:app",
|
||||
"--host", "127.0.0.1", "--port", str(PORT)],
|
||||
cwd=ROOT, env=os.environ,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
_wait_ready()
|
||||
|
||||
import test_api # noqa: E402 (tests/ on sys.path via script dir)
|
||||
# order matters: lockout test locks bob in memory, run it last
|
||||
api_tests = [fn for n, fn in sorted(vars(test_api).items())
|
||||
if n.startswith("test_") and n != "test_lockout_per_user"]
|
||||
for fn in api_tests:
|
||||
print(f"ok api.{fn.__name__}")
|
||||
fn(BASE)
|
||||
print("ok api.test_lockout_per_user")
|
||||
test_api.test_lockout_per_user(BASE)
|
||||
|
||||
try:
|
||||
import test_browser
|
||||
except ImportError:
|
||||
print("SKIP browser tests (pip install playwright && playwright install chromium)")
|
||||
else:
|
||||
print("ok browser (playwright)")
|
||||
asyncio.run(test_browser.test_login_ui())
|
||||
asyncio.run(test_browser.test_video_plays_behind_auth())
|
||||
|
||||
print("ALL TESTS PASSED")
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
shutil.rmtree(data, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,158 @@
|
||||
"""HTTP-level tests for ani-web (stdlib only). Run via tests/run.py or
|
||||
standalone against a running server: python tests/test_api.py <base_url>
|
||||
|
||||
Uses a throwaway server + temp data dir (see run.py) — never touches
|
||||
the real server/data. Seeded users: admin/alice/bob (pw <name>pw12345).
|
||||
"""
|
||||
import http.cookiejar as _cj
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://127.0.0.1:8399"
|
||||
CJ = lambda: _cj.CookieJar()
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""urlopen follows 302s by default; we want to *see* them."""
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
def no_redirect(method, path, data=None, base=BASE):
|
||||
"""Request without following redirects; returns (status, headers)."""
|
||||
opener = urllib.request.build_opener(_NoRedirect)
|
||||
r = urllib.request.Request(base + path,
|
||||
data=json.dumps(data).encode() if data is not None else None,
|
||||
method=method,
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
resp = opener.open(r)
|
||||
return resp.getcode(), resp.headers
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.headers
|
||||
|
||||
|
||||
def no_redirect_get(path, base=BASE):
|
||||
return no_redirect("GET", path, base=base)
|
||||
|
||||
|
||||
def req(method, path, data=None, jar=None, base=BASE):
|
||||
r = urllib.request.Request(base + path,
|
||||
data=json.dumps(data).encode() if data is not None else None,
|
||||
method=method, headers={"Content-Type": "application/json"})
|
||||
if jar is not None:
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
try:
|
||||
return opener.open(r).getcode()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code
|
||||
try:
|
||||
return urllib.request.urlopen(r).status
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code
|
||||
|
||||
|
||||
def login(user, pw, base=BASE):
|
||||
jar = CJ()
|
||||
assert req("POST", "/login", {"username": user, "password": pw}, jar, base) == 200, user
|
||||
return jar
|
||||
|
||||
|
||||
def test_unauthenticated(base=BASE):
|
||||
# page -> 302 to /login, api -> 401
|
||||
code, headers = no_redirect_get("/", base)
|
||||
assert code == 302 and "/login" in headers["Location"], (code, headers.get("Location"))
|
||||
assert req("GET", "/api/search?q=x", base=base) == 401
|
||||
|
||||
|
||||
def test_wrong_password(base=BASE):
|
||||
assert req("POST", "/login", {"username": "admin", "password": "nope"}, base=base) == 401
|
||||
|
||||
|
||||
def test_login_and_access(base=BASE):
|
||||
jar = login("admin", "adminpw12345", base)
|
||||
assert req("GET", "/api/search?q=x", jar=jar, base=base) == 200
|
||||
assert req("GET", "/api/me", jar=jar, base=base) == 200
|
||||
|
||||
|
||||
def test_logout(base=BASE):
|
||||
jar = login("admin", "adminpw12345", base)
|
||||
assert req("POST", "/logout", jar=jar, base=base) == 200
|
||||
# session gone: cookie jar emptied by the delete-cookie response
|
||||
assert not [c for c in jar if c.name == "aniweb_session"], "cookie not cleared"
|
||||
# and a cookie-less request is bounced to /login
|
||||
code, headers = no_redirect_get("/", base)
|
||||
assert code == 302 and "/login" in headers["Location"]
|
||||
|
||||
|
||||
def test_no_self_signup(base=BASE):
|
||||
# /signup route removed — unauthenticated POST bounces to /login
|
||||
code, headers = no_redirect("POST", "/signup",
|
||||
{"username": "zed", "password": "whatever123"}, base)
|
||||
assert code in (302, 404, 405), code
|
||||
# and even authenticated it's gone (no such route)
|
||||
jar = login("admin", "adminpw12345", base)
|
||||
assert req("POST", "/signup", {"username": "zed", "password": "whatever123"},
|
||||
jar=jar, base=base) == 405
|
||||
assert "zed" not in json.load(open(_users_file()))
|
||||
|
||||
|
||||
def test_history_requires_ep_id(base=BASE):
|
||||
jar = login("admin", "adminpw12345", base)
|
||||
assert req("POST", "/api/history", {"slug_id": "x"}, jar=jar, base=base) == 422
|
||||
|
||||
|
||||
def test_history_per_user(base=BASE):
|
||||
a = login("admin", "adminpw12345", base)
|
||||
b = login("alice", "alicepw12345", base)
|
||||
entry = {"ep_id": "111", "slug_id": "test-111", "title": "Test", "season_id": 1,
|
||||
"ep": "111", "lang": "eng", "pos": 42.0}
|
||||
assert req("POST", "/api/history", entry, jar=a, base=base) == 200
|
||||
got_a = _json("GET", "/api/history", jar=a, base=base)
|
||||
got_b = _json("GET", "/api/history", jar=b, base=base)
|
||||
assert any(e["ep_id"] == "111" for e in got_a)
|
||||
assert all(e.get("ep_id") != "111" for e in got_b), "leak: admin entry in alice's history"
|
||||
|
||||
|
||||
def test_self_delete(base=BASE):
|
||||
jar = login("dave", "davepw12345", base)
|
||||
assert req("POST", "/account/delete", jar=jar, base=base) == 200
|
||||
assert "dave" not in json.load(open(_users_file()))
|
||||
jar2 = CJ()
|
||||
assert req("POST", "/login", {"username": "dave", "password": "davepw12345"}, jar2, base=base) == 401
|
||||
|
||||
|
||||
def test_lockout_per_user(base=BASE):
|
||||
# must run on a fresh server (in-memory lockout): 5 fails -> 429 for bob,
|
||||
# admin unaffected
|
||||
codes = [req("POST", "/login", {"username": "bob", "password": "bad"}, base=base)
|
||||
for _ in range(6)]
|
||||
assert codes[:5] == [401] * 5 and codes[5] == 429, codes
|
||||
assert req("POST", "/login", {"username": "admin", "password": "adminpw12345"}, base=base) == 200
|
||||
|
||||
|
||||
# --- helpers ---
|
||||
|
||||
def _json(method, path, jar=None, base=BASE):
|
||||
r = urllib.request.Request(base + path, method=method)
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) if jar else urllib.request
|
||||
try:
|
||||
resp = opener.open(r)
|
||||
except urllib.error.HTTPError as e:
|
||||
raise AssertionError(f"{method} {path} -> {e.code}")
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def _users_file():
|
||||
import os
|
||||
return os.path.join(os.environ["ANIWEB_DATA"], "users.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
b = sys.argv[1] if len(sys.argv) > 1 else BASE
|
||||
for name, fn in sorted(globals().items()):
|
||||
if name.startswith("test_"):
|
||||
print("ok", name)
|
||||
fn(b)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Browser tests for ani-web (Playwright, headless Chromium).
|
||||
|
||||
Run via tests/run.py or standalone: python tests/test_browser.py <base_url>
|
||||
Covers what HTTP tests can't: the login UI, the user chip, and real
|
||||
video playback behind auth. Needs: pip install playwright && playwright install chromium
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:8399"
|
||||
LAUNCH_ARGS = ["--autoplay-policy=no-user-gesture-required"]
|
||||
|
||||
|
||||
async def _login(page, user, pw):
|
||||
await page.goto(BASE + "/")
|
||||
await page.wait_for_selector("input#pw")
|
||||
assert "create account" not in await page.content(), "self-signup UI must be absent"
|
||||
await page.fill("#user", user)
|
||||
await page.fill("#pw", pw)
|
||||
await page.click("#go")
|
||||
await page.wait_for_selector("#q", timeout=15000)
|
||||
await page.wait_for_function(
|
||||
f"document.getElementById('me').textContent.includes('{user}')", timeout=5000)
|
||||
|
||||
|
||||
async def test_login_ui():
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(args=LAUNCH_ARGS)
|
||||
page = await (await browser.new_context()).new_page()
|
||||
errors = []
|
||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||
|
||||
# unauth -> login form
|
||||
await page.goto(BASE + "/")
|
||||
await page.wait_for_selector("input#pw")
|
||||
assert "/login" in page.url, page.url
|
||||
|
||||
# wrong password -> inline error
|
||||
await page.fill("#user", "admin")
|
||||
await page.fill("#pw", "wrong")
|
||||
await page.click("#go")
|
||||
await page.wait_for_function("document.querySelector('#err').textContent.length > 0",
|
||||
timeout=10000)
|
||||
assert "wrong" in await page.inner_text("#err")
|
||||
|
||||
# correct -> home with user chip
|
||||
await page.fill("#pw", "adminpw12345")
|
||||
await page.click("#go")
|
||||
await _login_settled(page)
|
||||
assert await page.inner_text("#me") == "hi, admin"
|
||||
assert "Recently played" in await page.inner_text("#main"), "seeded history must show"
|
||||
assert not errors, errors
|
||||
await browser.close()
|
||||
print("ok test_login_ui")
|
||||
|
||||
|
||||
async def _login_settled(page):
|
||||
await page.wait_for_selector("#q", timeout=15000)
|
||||
await page.wait_for_function(
|
||||
"document.getElementById('me').textContent.length > 0", timeout=5000)
|
||||
|
||||
|
||||
async def test_video_plays_behind_auth():
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(args=LAUNCH_ARGS)
|
||||
page = await (await browser.new_context()).new_page()
|
||||
errors = []
|
||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
|
||||
|
||||
await _login(page, "alice", "alicepw12345")
|
||||
await page.fill("#q", "chainsaw")
|
||||
await page.keyboard.press("Enter")
|
||||
await page.wait_for_selector(".card", timeout=30000)
|
||||
await page.click(".card")
|
||||
await page.wait_for_selector("video", timeout=30000)
|
||||
await page.wait_for_function(
|
||||
"document.querySelector('video').readyState >= 4 && document.querySelector('video').currentTime > 1",
|
||||
timeout=60000)
|
||||
real_errors = [e for e in errors if "401" not in e]
|
||||
assert not real_errors, real_errors
|
||||
await browser.close()
|
||||
print("ok test_video_plays_behind_auth")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1:
|
||||
BASE = sys.argv[1]
|
||||
asyncio.run(test_login_ui())
|
||||
asyncio.run(test_video_plays_behind_auth())
|
||||
Reference in new issue
Block a user