Private
Public Access
A video file previously rendered as a generic downloadable file card, same as any other attachment. The file-serve endpoint forces Content-Disposition: attachment for every upload as an XSS mitigation (a same-origin-served .html/.svg executing script), which also meant a <video> tag pointed at it couldn't play -- the browser would just try to download it. Carve out a strict, server-side allowlist (video/mp4, video/webm, video/ogg -- deliberately not "every video/* type") that skips the forced download, the same reasoning MessageImage's own endpoint already relies on: these are content types a browser only ever interprets as media, never as something that could execute script. Anything else, including other video formats like .mov, still forces a download exactly as before. On the frontend, a video attachment with one of those content types renders as an inline <video controls> instead of the generic file card, with a hover-revealed expand button that calls the browser's native Fullscreen API on the video element directly rather than building a second lightbox component. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
236 lines
9.4 KiB
Python
236 lines
9.4 KiB
Python
import uuid
|
|
|
|
from tests.conftest import register_and_login
|
|
|
|
|
|
def _unique(prefix: str) -> str:
|
|
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
async def test_upload_file_succeeds(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
|
|
|
|
resp = await client.post(
|
|
f"/api/rooms/{room['id']}/files",
|
|
files={"file": ("report.pdf", b"%PDF-1.4 not a real pdf", "application/pdf")},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
body = resp.json()
|
|
assert "id" in body
|
|
assert body["filename"] == "report.pdf"
|
|
assert body["size_bytes"] == len(b"%PDF-1.4 not a real pdf")
|
|
assert body["content_type"] == "application/pdf"
|
|
|
|
|
|
async def test_upload_any_content_type_accepted(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
|
|
|
|
# Unlike /images, /files has no content-type allowlist -- arbitrary
|
|
# files are the point of this endpoint.
|
|
resp = await client.post(
|
|
f"/api/rooms/{room['id']}/files",
|
|
files={"file": ("archive.zip", b"PK\x03\x04 not a real zip", "application/zip")},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
|
|
|
|
async def test_upload_oversized_file_rejected(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
|
|
|
|
oversized = b"0" * (9 * 1024 * 1024)
|
|
resp = await client.post(
|
|
f"/api/rooms/{room['id']}/files",
|
|
files={"file": ("huge.bin", oversized, "application/octet-stream")},
|
|
)
|
|
assert resp.status_code == 413
|
|
|
|
|
|
async def test_serve_file_requires_room_membership(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
|
|
upload = await client.post(
|
|
f"/api/rooms/{room['id']}/files",
|
|
files={"file": ("notes.txt", b"hello", "text/plain")},
|
|
)
|
|
file_id = upload.json()["id"]
|
|
|
|
ok = await client.get(f"/api/rooms/{room['id']}/files/{file_id}")
|
|
assert ok.status_code == 200
|
|
|
|
await register_and_login(client, db_session, username=_unique("bob"))
|
|
resp = await client.get(f"/api/rooms/{room['id']}/files/{file_id}")
|
|
assert resp.status_code == 403
|
|
|
|
|
|
async def test_serve_file_404s_for_wrong_room(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
room_a = (await client.post("/api/rooms", json={"name": _unique("room-a")})).json()
|
|
room_b = (await client.post("/api/rooms", json={"name": _unique("room-b")})).json()
|
|
upload = await client.post(
|
|
f"/api/rooms/{room_a['id']}/files",
|
|
files={"file": ("notes.txt", b"hello", "text/plain")},
|
|
)
|
|
file_id = upload.json()["id"]
|
|
|
|
resp = await client.get(f"/api/rooms/{room_b['id']}/files/{file_id}")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
async def test_serve_file_forces_download(client, db_session):
|
|
# The security-relevant assertion for this feature: a user-uploaded
|
|
# file must never render inline (e.g. an uploaded .html executing
|
|
# script same-origin) -- Content-Disposition: attachment forces a
|
|
# download in the browser regardless of content-type.
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
|
|
upload = await client.post(
|
|
f"/api/rooms/{room['id']}/files",
|
|
files={"file": ("notes.txt", b"hello", "text/plain")},
|
|
)
|
|
file_id = upload.json()["id"]
|
|
|
|
resp = await client.get(f"/api/rooms/{room['id']}/files/{file_id}")
|
|
assert resp.status_code == 200
|
|
disposition = resp.headers["content-disposition"]
|
|
assert "attachment" in disposition
|
|
assert "notes.txt" in disposition
|
|
|
|
|
|
async def test_serve_allowlisted_video_inline(client, db_session):
|
|
# #65: a <video> tag can't play something the browser is forced to
|
|
# download instead -- browser-playable video types are the one carve-
|
|
# out from test_serve_file_forces_download's rule above.
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
|
|
upload = await client.post(
|
|
f"/api/rooms/{room['id']}/files",
|
|
files={"file": ("clip.mp4", b"not a real mp4", "video/mp4")},
|
|
)
|
|
file_id = upload.json()["id"]
|
|
|
|
resp = await client.get(f"/api/rooms/{room['id']}/files/{file_id}")
|
|
assert resp.status_code == 200
|
|
assert "content-disposition" not in resp.headers
|
|
assert resp.headers["content-type"] == "video/mp4"
|
|
|
|
|
|
async def test_serve_non_allowlisted_video_still_forces_download(client, db_session):
|
|
# video/quicktime (.mov) has spotty <video> support outside Safari, and
|
|
# more importantly this proves the carve-out is a strict allowlist, not
|
|
# "every video/* content type" -- the security-relevant boundary from
|
|
# test_serve_file_forces_download must still hold for anything not on
|
|
# INLINE_SAFE_VIDEO_CONTENT_TYPES.
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
|
|
upload = await client.post(
|
|
f"/api/rooms/{room['id']}/files",
|
|
files={"file": ("clip.mov", b"not a real mov", "video/quicktime")},
|
|
)
|
|
file_id = upload.json()["id"]
|
|
|
|
resp = await client.get(f"/api/rooms/{room['id']}/files/{file_id}")
|
|
assert resp.status_code == 200
|
|
assert "attachment" in resp.headers["content-disposition"]
|
|
|
|
|
|
def _register_ws(ws_client, username: str) -> dict:
|
|
from app.schemas.user import UserCreate
|
|
from app.services.auth_service import register_user
|
|
|
|
async def _seed():
|
|
async with ws_client.session_factory() as session:
|
|
await register_user(
|
|
session,
|
|
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
|
)
|
|
|
|
ws_client.portal.call(_seed)
|
|
resp = ws_client.post(
|
|
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
return resp.json()
|
|
|
|
|
|
def test_ws_file_only_message_roundtrips(ws_client):
|
|
_register_ws(ws_client, _unique("alice"))
|
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
upload = ws_client.post(
|
|
f"/api/rooms/{room['id']}/files",
|
|
files={"file": ("notes.txt", b"hello", "text/plain")},
|
|
)
|
|
file_id = upload.json()["id"]
|
|
|
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert ws.receive_json()["type"] == "joined"
|
|
|
|
ws.send_json({"type": "message", "room_id": room["id"], "file_id": file_id})
|
|
message = ws.receive_json()
|
|
assert message["type"] == "message"
|
|
assert message["content"] is None
|
|
assert message["file"]["id"] == file_id
|
|
assert message["file"]["filename"] == "notes.txt"
|
|
assert message["file"]["size_bytes"] == len(b"hello")
|
|
|
|
history = ws_client.get(f"/api/rooms/{room['id']}/messages").json()
|
|
persisted = next(m for m in history if m["id"] == message["id"])
|
|
assert persisted["content"] is None
|
|
assert persisted["file"]["id"] == file_id
|
|
assert persisted["file"]["filename"] == "notes.txt"
|
|
|
|
|
|
def test_ws_message_requires_content_or_image_or_file(ws_client):
|
|
_register_ws(ws_client, _unique("alice"))
|
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert ws.receive_json()["type"] == "joined"
|
|
|
|
ws.send_json({"type": "message", "room_id": room["id"]})
|
|
resp = ws.receive_json()
|
|
assert resp["type"] == "error"
|
|
|
|
|
|
def test_push_body_says_sent_a_file_for_file_only_message(ws_client, monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
|
|
|
|
alice = _register_ws(ws_client, _unique("alice"))
|
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
_register_ws(ws_client, _unique("bob"))
|
|
ws_client.post(f"/api/rooms/{room['id']}/join")
|
|
ws_client.post(
|
|
"/api/push/subscribe",
|
|
json={
|
|
"endpoint": f"https://push.example.com/ep-{_unique('bob')}",
|
|
"keys": {"p256dh": "p256dh-bob", "auth": "auth-bob"},
|
|
},
|
|
)
|
|
|
|
ws_client.post(
|
|
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
|
)
|
|
upload = ws_client.post(
|
|
f"/api/rooms/{room['id']}/files",
|
|
files={"file": ("notes.txt", b"hello", "text/plain")},
|
|
)
|
|
file_id = upload.json()["id"]
|
|
|
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert ws.receive_json()["type"] == "joined"
|
|
ws.send_json({"type": "message", "room_id": room["id"], "file_id": file_id})
|
|
assert ws.receive_json()["type"] == "message"
|
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert ws.receive_json()["type"] == "joined"
|
|
|
|
assert len(calls) == 1
|
|
assert "sent a file" in calls[0]["data"]
|