383 lines
15 KiB
Python
383 lines
15 KiB
Python
import json
|
||
import math
|
||
import re
|
||
from urllib.parse import quote, unquote
|
||
|
||
import httpx
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.server import Settings, create_app
|
||
|
||
|
||
def mkfile(fid, name, size=100, thumb=True):
|
||
return {
|
||
"id": fid, "name": name, "originalName": name, "size": size, "type": "video/mp4", "views": 3,
|
||
"createdAt": "2026-10-01T16:46:15.378Z", "deletesAt": "2026-12-30T16:46:15.375Z",
|
||
"thumbnail": {"path": f".thumbnail.{fid}.jpg"} if thumb else None, "tags": [], "url": "/" + name,
|
||
}
|
||
|
||
|
||
class FakeZipline:
|
||
def __init__(self):
|
||
self.users = {"alice": ("pw", "tok-alice"), "bob": ("pw2", "tok-bob")}
|
||
self.files = {
|
||
"tok-alice": [mkfile("e1", "clutch.mp4"), mkfile("e2", "çılgın-#1.mp4", thumb=False)],
|
||
"tok-bob": [mkfile("c1", "friend.mp4")],
|
||
}
|
||
self.down = False
|
||
self.calls = []
|
||
|
||
def owner(self, tok):
|
||
return next((name for name, (_, t) in self.users.items() if t == tok), None)
|
||
|
||
def handler(self, request: httpx.Request) -> httpx.Response:
|
||
if self.down:
|
||
raise httpx.ConnectError("down")
|
||
path, method = request.url.path, request.method
|
||
self.calls.append((method, path, request.headers.get("authorization"), request.headers.get("cookie")))
|
||
if path == "/api/auth/login":
|
||
body = json.loads(request.content)
|
||
user = self.users.get(body["username"])
|
||
if not user or user[0] != body["password"]:
|
||
return httpx.Response(400, json={"error": "E1044: Invalid username or password"})
|
||
return httpx.Response(
|
||
200, json={"user": {"id": "u-" + body["username"], "username": body["username"]}},
|
||
headers={"set-cookie": f"zipline_session=sess-{body['username']}; Max-Age=1209600; Path=/; HttpOnly; Secure; SameSite=Lax"},
|
||
)
|
||
if path == "/api/user/token":
|
||
cookie = request.headers.get("cookie") or ""
|
||
for name, (_, tok) in self.users.items():
|
||
if f"zipline_session=sess-{name}" in cookie:
|
||
return httpx.Response(200, json={"token": tok})
|
||
return httpx.Response(401, json={"error": "E2001: Invalid token"})
|
||
if method == "HEAD":
|
||
name = unquote(path[1:])
|
||
f = next((f for files in self.files.values() for f in files if f["name"] == name), None)
|
||
if f is None:
|
||
return httpx.Response(404)
|
||
return httpx.Response(200, headers={"content-type": f["type"], "content-length": str(f["size"])})
|
||
tok = request.headers.get("authorization")
|
||
who = self.owner(tok)
|
||
if who is None:
|
||
return httpx.Response(401, json={"error": "E2001: Invalid token"})
|
||
mine = self.files.setdefault(tok, [])
|
||
if path == "/api/user":
|
||
return httpx.Response(200, json={"user": {"id": "u-" + who, "username": who, "role": "USER"}})
|
||
if path == "/api/user/stats":
|
||
return httpx.Response(200, json={"storageUsed": sum(f["size"] for f in mine)})
|
||
if path == "/api/user/files":
|
||
page = int(request.url.params.get("page", "1"))
|
||
per = int(request.url.params.get("perpage", "15"))
|
||
chunk = mine[(page - 1) * per: page * per]
|
||
return httpx.Response(200, json={"page": chunk, "total": len(mine), "pages": max(1, math.ceil(len(mine) / per))})
|
||
if path.startswith("/api/user/files/"):
|
||
ident = unquote(path[len("/api/user/files/"):])
|
||
f = next((f for f in mine if ident in (f["id"], f["name"])), None)
|
||
if f is None:
|
||
return httpx.Response(404, json={"error": "E4000: File not found"})
|
||
if method == "GET":
|
||
return httpx.Response(200, json=f)
|
||
if method == "PATCH":
|
||
f["name"] = json.loads(request.content)["name"]
|
||
return httpx.Response(200, json=f)
|
||
if method == "DELETE":
|
||
mine.remove(f)
|
||
return httpx.Response(200, json=f)
|
||
return httpx.Response(404, json={"error": "not found"})
|
||
|
||
|
||
@pytest.fixture
|
||
def zl():
|
||
return FakeZipline()
|
||
|
||
|
||
@pytest.fixture
|
||
def client(zl, tmp_path):
|
||
settings = Settings(zipline_url="http://zipline:3000", site_origin="https://clips.example.com",
|
||
upload_url="https://upload.example.com", max_bytes=1000, deletes_at="90d", work_dir=tmp_path)
|
||
return TestClient(create_app(settings, transport=httpx.MockTransport(zl.handler)), base_url="https://testserver")
|
||
|
||
|
||
def login(client, user="alice", pw="pw", ip="1.2.3.4"):
|
||
return client.post("/_site/api/login", json={"username": user, "password": pw}, headers={"cf-connecting-ip": ip})
|
||
|
||
|
||
def test_pages_serve_the_site(client):
|
||
for path in ("/", "/v/clutch.mp4", "/v/%C3%A7%C4%B1lg%C4%B1n-%231.mp4"):
|
||
r = client.get(path)
|
||
assert r.status_code == 200
|
||
assert "text/html" in r.headers["content-type"]
|
||
assert "/_site/static/app.js" in r.text
|
||
assert client.get("/_site/static/style.css").status_code == 200
|
||
|
||
|
||
@pytest.fixture
|
||
def fake_media(monkeypatch):
|
||
seen = []
|
||
monkeypatch.setattr("app.site_api.video_size", lambda url, length: seen.append(url) or (1920, 1080))
|
||
monkeypatch.setattr("app.site_api.still_jpeg", lambda url, length: seen.append(url) or b"jpeg-bytes")
|
||
return seen
|
||
|
||
|
||
def test_watch_page_has_discord_player_tags(client, fake_media):
|
||
# Discord gives up on big raw links; the /v/ page hands it the player URL, size and a still instead
|
||
r = client.get("/v/%C3%A7%C4%B1lg%C4%B1n-%231.mp4")
|
||
clip = "https://clips.example.com/%C3%A7%C4%B1lg%C4%B1n-%231.mp4"
|
||
assert f'<meta property="og:video" content="{clip}">' in r.text
|
||
assert '<meta property="og:video:type" content="video/mp4">' in r.text
|
||
assert '<meta property="og:video:width" content="1920">' in r.text
|
||
assert '<meta property="og:video:height" content="1080">' in r.text
|
||
assert ('<meta property="og:image" content="https://clips.example.com/_site/thumb/'
|
||
'%C3%A7%C4%B1lg%C4%B1n-%231.mp4">') in r.text
|
||
assert fake_media == ["http://zipline:3000/%C3%A7%C4%B1lg%C4%B1n-%231.mp4"]
|
||
|
||
|
||
def test_shared_clip_gets_a_random_stripe_color(client, fake_media):
|
||
colors = {re.search(r'name="theme-color" content="(#[0-9a-f]{6})"', client.get("/v/clutch.mp4").text).group(1)
|
||
for _ in range(5)}
|
||
assert len(colors) > 1 and "#3e4637" not in colors
|
||
assert 'name="theme-color" content="#3e4637"' in client.get("/").text
|
||
|
||
|
||
def test_watch_page_without_clip_has_no_player_tags(client, fake_media):
|
||
for path in ("/v/nope.mp4", '/v/"><script>alert(1)</script>'):
|
||
r = client.get(path)
|
||
assert r.status_code == 200
|
||
assert "og:video" not in r.text and "<script>alert" not in r.text
|
||
assert fake_media == []
|
||
|
||
|
||
def test_title_is_escaped(zl, client, fake_media):
|
||
zl.files["tok-alice"].append(mkfile("e3", 'a"><b>.mp4'))
|
||
r = client.get("/v/" + quote('a"><b>.mp4'))
|
||
assert '<meta property="og:title" content="a"><b>.mp4">' in r.text
|
||
assert '"><b>' not in r.text
|
||
|
||
|
||
def test_thumbnail(client, fake_media):
|
||
r = client.get("/_site/thumb/clutch.mp4")
|
||
assert r.status_code == 200 and r.headers["content-type"] == "image/jpeg"
|
||
assert r.content == b"jpeg-bytes"
|
||
assert client.get("/_site/thumb/nope.mp4").status_code == 404
|
||
|
||
|
||
def test_public_file_needs_no_login(client):
|
||
r = client.get("/_site/api/public/clutch.mp4")
|
||
assert r.status_code == 200
|
||
assert r.json() == {"name": "clutch.mp4", "type": "video/mp4", "size": 100, "url": "/clutch.mp4",
|
||
"thumbnail": "/_site/thumb/clutch.mp4"}
|
||
assert r.headers["cache-control"] == "no-store, private"
|
||
assert client.get("/_site/api/public/nope.mp4").status_code == 404
|
||
|
||
|
||
def test_public_file_when_zipline_is_down(zl, client):
|
||
zl.down = True
|
||
assert client.get("/_site/api/public/clutch.mp4").status_code == 502
|
||
|
||
|
||
def test_config(client):
|
||
assert client.get("/_site/api/config").json() == {
|
||
"uploadUrl": "https://upload.example.com", "publicUrl": "https://clips.example.com",
|
||
"uploadHint": "Can't reach the upload server."}
|
||
|
||
|
||
def test_site_and_server_names_reach_the_page(zl, tmp_path, fake_media):
|
||
settings = Settings(zipline_url="http://zipline:3000", site_origin="https://clips.example.com",
|
||
site_name='my <clips>', server_name='box"1', work_dir=tmp_path)
|
||
client = TestClient(create_app(settings, transport=httpx.MockTransport(zl.handler)), base_url="https://testserver")
|
||
page = client.get("/").text
|
||
assert "<title>my <clips></title>" in page
|
||
assert 'data-server="box"1"' in page
|
||
assert '<meta property="og:site_name" content="my <clips>">' in client.get("/v/clutch.mp4").text
|
||
|
||
|
||
def test_login_sets_cookie(client):
|
||
r = login(client)
|
||
assert r.status_code == 200
|
||
assert r.json() == {"username": "alice"}
|
||
sc = r.headers["set-cookie"]
|
||
assert "ys=tok-alice" in sc
|
||
for flag in ("HttpOnly", "Secure", "SameSite=lax", "Max-Age=2592000", "Path=/"):
|
||
assert flag in sc
|
||
|
||
|
||
def test_login_wrong_password(client):
|
||
r = login(client, pw="nope")
|
||
assert r.status_code == 401
|
||
assert r.json() == {"error": "Wrong username or password."}
|
||
assert "ys=" not in r.headers.get("set-cookie", "")
|
||
|
||
|
||
def test_login_rate_limit_per_ip(client):
|
||
for _ in range(5):
|
||
assert login(client, pw="nope").status_code == 401
|
||
r = login(client)
|
||
assert r.status_code == 429
|
||
assert r.json() == {"error": "Too many tries, wait a few minutes."}
|
||
assert login(client, ip="9.9.9.9").status_code == 200
|
||
|
||
|
||
def test_login_zipline_down(client, zl):
|
||
zl.down = True
|
||
assert login(client).status_code == 502
|
||
|
||
|
||
def test_two_logins_get_own_tokens(client):
|
||
login(client, "alice", "pw")
|
||
r = login(client, "bob", "pw2")
|
||
assert "ys=tok-bob" in r.headers["set-cookie"]
|
||
assert client.get("/_site/api/me").json()["username"] == "bob"
|
||
|
||
|
||
def test_me_requires_login(client):
|
||
assert client.get("/_site/api/me").status_code == 401
|
||
|
||
|
||
def test_me(client):
|
||
login(client)
|
||
assert client.get("/_site/api/me").json() == {"username": "alice", "storageUsed": 200}
|
||
|
||
|
||
def test_revoked_token_clears_cookie(client):
|
||
client.cookies.set("ys", "revoked", domain="testserver")
|
||
r = client.get("/_site/api/files")
|
||
assert r.status_code == 401
|
||
assert 'ys=""' in r.headers["set-cookie"] or "ys=;" in r.headers["set-cookie"]
|
||
assert "Max-Age=0" in r.headers["set-cookie"]
|
||
|
||
|
||
def test_logout(client):
|
||
login(client)
|
||
r = client.post("/_site/api/logout")
|
||
assert r.json() == {"ok": True}
|
||
assert "Max-Age=0" in r.headers["set-cookie"]
|
||
assert client.get("/_site/api/me").status_code == 401
|
||
|
||
|
||
def test_files_list(client, zl):
|
||
login(client)
|
||
d = client.get("/_site/api/files").json()
|
||
assert d["total"] == 2 and d["pages"] == 1
|
||
first = d["files"][0]
|
||
assert first == {
|
||
"id": "e1", "name": "clutch.mp4", "size": 100, "type": "video/mp4", "views": 3,
|
||
"createdAt": "2026-10-01T16:46:15.378Z", "deletesAt": "2026-12-30T16:46:15.375Z",
|
||
"url": "/clutch.mp4", "thumbnail": "/raw/.thumbnail.e1.jpg",
|
||
}
|
||
assert d["files"][1]["url"] == "/" + quote("çılgın-#1.mp4")
|
||
assert d["files"][1]["thumbnail"] is None
|
||
sent = [c for c in zl.calls if c[1] == "/api/user/files"][0]
|
||
assert sent[2] == "tok-alice"
|
||
|
||
|
||
def test_files_paging(client, zl):
|
||
zl.files["tok-alice"] = [mkfile(f"f{i}", f"c{i}.mp4") for i in range(30)]
|
||
login(client)
|
||
p1 = client.get("/_site/api/files?page=1").json()
|
||
p2 = client.get("/_site/api/files?page=2").json()
|
||
assert len(p1["files"]) == 24 and p1["pages"] == 2
|
||
assert len(p2["files"]) == 6
|
||
|
||
|
||
def test_friend_cannot_see_alice_file(client):
|
||
login(client, "bob", "pw2")
|
||
r = client.get("/_site/api/files/e1")
|
||
assert r.status_code == 404
|
||
assert r.json() == {"error": "This clip doesn't exist (or isn't yours)."}
|
||
|
||
|
||
def test_file_by_unicode_name(client):
|
||
login(client)
|
||
r = client.get("/_site/api/files/" + quote("çılgın-#1.mp4"))
|
||
assert r.status_code == 200
|
||
assert r.json()["id"] == "e2"
|
||
|
||
|
||
def test_rename(client, zl):
|
||
login(client)
|
||
r = client.patch("/_site/api/files/e1", json={"name": "best clip ever.mp4"})
|
||
assert r.json() == {"name": "best-clip-ever.mp4"}
|
||
assert zl.files["tok-alice"][0]["name"] == "best-clip-ever.mp4"
|
||
|
||
|
||
def test_rename_strips_path(client, zl):
|
||
login(client)
|
||
assert client.patch("/_site/api/files/e1", json={"name": "../x y.mp4"}).json() == {"name": "x-y.mp4"}
|
||
|
||
|
||
def test_rename_taken(client, zl):
|
||
login(client)
|
||
r = client.patch("/_site/api/files/e1", json={"name": "friend.mp4"})
|
||
assert r.status_code == 409
|
||
assert r.json() == {"error": "That name is already used."}
|
||
assert not [c for c in zl.calls if c[0] == "PATCH"]
|
||
|
||
|
||
def test_delete(client, zl):
|
||
login(client)
|
||
assert client.delete("/_site/api/files/e1").json() == {"ok": True}
|
||
assert [f["id"] for f in zl.files["tok-alice"]] == ["e2"]
|
||
|
||
|
||
def test_files_zipline_down(client, zl):
|
||
login(client)
|
||
zl.down = True
|
||
r = client.get("/_site/api/files")
|
||
assert r.status_code == 502
|
||
assert r.json() == {"error": "Zipline isn't reachable right now."}
|
||
|
||
|
||
def test_upload_ticket(client):
|
||
assert client.post("/_site/api/upload-ticket").status_code == 401
|
||
login(client)
|
||
d = client.post("/_site/api/upload-ticket").json()
|
||
assert d["uploadUrl"] == "https://upload.example.com"
|
||
assert client.app.state.tickets.redeem(d["ticket"]) == "tok-alice"
|
||
|
||
|
||
def test_assets_are_versioned_and_page_not_cached(client):
|
||
# Cloudflare keeps .js/.css in browsers for 4 hours; a content hash in the URL makes updates show up at once
|
||
r = client.get("/")
|
||
assert r.headers["cache-control"] == "no-store"
|
||
assert client.get("/v/clip.mp4").headers["cache-control"] == "no-store"
|
||
assert "/_site/static/app.js?v=" in r.text
|
||
assert "/_site/static/style.css?v=" in r.text
|
||
assert "{{v}}" not in r.text
|
||
|
||
|
||
def test_api_answers_are_never_cached(client):
|
||
# Cloudflare caches anything ending in .mp4 by extension; a cached clip lookup would leak between users
|
||
for r in (client.get("/_site/api/files/clutch.mp4"), login(client), client.get("/_site/api/files/clutch.mp4"),
|
||
client.get("/_site/api/files/nope.mp4"), client.get("/_site/api/me"), client.get("/_site/api/config")):
|
||
assert r.headers.get("cache-control") == "no-store, private"
|
||
|
||
|
||
def test_login_limit_uses_forwarded_for_without_cloudflare(client):
|
||
# over the Tailscale address there's no CF-Connecting-IP; tailscale serve passes the tailnet IP in X-Forwarded-For
|
||
def tailnet_login(ip, pw):
|
||
return client.post("/_site/api/login", json={"username": "alice", "password": pw},
|
||
headers={"x-forwarded-for": ip})
|
||
for _ in range(5):
|
||
tailnet_login("100.64.0.5", "nope")
|
||
assert tailnet_login("100.64.0.5", "pw").status_code == 429
|
||
assert tailnet_login("100.64.0.9", "pw").status_code == 200
|
||
|
||
|
||
def test_other_account_login_doesnt_reset_the_limit(client):
|
||
# bob guessing alice's password can't clear their strikes by logging in as themselves
|
||
for _ in range(4):
|
||
login(client, "alice", "nope")
|
||
assert login(client, "bob", "pw2").status_code == 200
|
||
login(client, "alice", "nope")
|
||
assert login(client, "alice", "pw").status_code == 429
|
||
|
||
|
||
def test_empty_or_nested_file_id_is_404(client, zl):
|
||
login(client)
|
||
before = len(zl.calls)
|
||
for r in (client.get("/_site/api/files/"), client.delete("/_site/api/files/a%2Fb"),
|
||
client.patch("/_site/api/files/x/transaction", json={"name": "y.mp4"})):
|
||
assert r.status_code == 404
|
||
assert len(zl.calls) == before
|