Files
kaledonya-clips/intake/tests/test_site_api.py
T

383 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&quot;&gt;&lt;b&gt;.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 &lt;clips&gt;</title>" in page
assert 'data-server="box&quot;1"' in page
assert '<meta property="og:site_name" content="my &lt;clips&gt;">' 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