Files
siftlode/backend/tests/test_links.py
T
peter fe0b34cd00 feat(downloads): local file upload ingest — media library backend (E7 S5a)
Add the non-yt-dlp ingest path so a user-uploaded file joins the same download
library and inherits its function set. Any file type is accepted: media
(video/audio) gets the full set (metadata, share, play/edit), a generic file
(pdf/zip/docx/…) is download-only.

- MediaAsset.media_kind ("video"|"audio"|"file") — the stored discriminator that
  gates library actions. Set at every finalize (download probe, editor carries the
  source's kind, upload probe); consolidates the ad-hoc audio-detect that lived only
  in links.py into one invariant (downloads.mediakind.derive). Migration 0076 adds
  the column + backfills existing rows.
- downloads/upload.py: ingest_upload probes the file (ffprobe → codecs/dims/duration),
  places it in a per-user .uploads tree, and creates an already-ready asset + done job
  (the same cache-hit shortcut enqueue uses). Uploads never expire (irreplaceable).
- POST /api/downloads/upload: streaming multipart with the per-file cap
  (download_upload_max_bytes, default 500 MiB) enforced on actual bytes read; quota
  charges the known size up front (quota.check_upload).
- Security: a generic file is always served force-download (attachment +
  application/octet-stream) on both the in-app and public endpoints, so uploaded
  HTML/SVG can't execute inline. Edit + redownload reject non-media / upload sources.
- python-multipart added (first multipart endpoint in the app).
2026-08-29 08:02:11 +02:00

130 lines
4.9 KiB
Python

"""Signed-grant HMAC for password-protected share links: a grant proves the viewer unlocked a
token, rides in the file URL (a <video src> can't send a header), and expires."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from app.downloads.links import (
_GRANT_TTL,
check_grant,
is_expired,
make_grant,
new_token,
public_meta,
)
def test_new_token_is_unguessable_and_unique():
a, b = new_token(), new_token()
assert a != b
assert len(a) >= 30 # token_urlsafe(24) → ~32 chars
class TestGrant:
def test_a_fresh_grant_verifies_for_its_own_token(self):
grant = make_grant("tok", 0)
assert check_grant("tok", grant, 0) is True
def test_a_grant_does_not_verify_for_a_different_token(self):
# The signature binds the token, so a grant minted for one link can't unlock another.
assert check_grant("other", make_grant("tok", 0), 0) is False
def test_a_tampered_signature_is_rejected(self):
exp, _sig = make_grant("tok", 0).split(".", 1)
assert check_grant("tok", f"{exp}.deadbeef", 0) is False
def test_a_tampered_expiry_is_rejected(self):
# Extending the exp field invalidates the signature (which covers token.exp.version).
_exp, sig = make_grant("tok", 0).split(".", 1)
future = int(datetime.now(timezone.utc).timestamp()) + 10 * _GRANT_TTL
assert check_grant("tok", f"{future}.{sig}", 0) is False
def test_an_expired_grant_is_rejected(self):
past = datetime.now(timezone.utc).timestamp() - 2 * _GRANT_TTL
assert check_grant("tok", make_grant("tok", 0, now=past), 0) is False
def test_a_password_rotation_invalidates_an_old_grant(self):
# A grant minted at version 0 must fail once the link's password_version has been bumped —
# this is what makes changing a link password revoke outstanding grants immediately.
grant = make_grant("tok", 0)
assert check_grant("tok", grant, 0) is True
assert check_grant("tok", grant, 1) is False
def test_malformed_grants_are_rejected_not_crashed(self):
for bad in [None, "", "no-dot", "notanumber.sig"]:
assert check_grant("tok", bad, 0) is False
def _asset(**over):
base = dict(
title="Clip",
uploader="Someone",
uploader_url=None,
duration_s=42,
width=1280,
height=720,
vcodec="h264",
acodec="aac",
media_kind="video",
size_bytes=1000,
container="mp4",
source_webpage_url=None,
source_kind="url",
)
base.update(over)
return SimpleNamespace(**base)
class TestPublicMetaAudioFlag:
"""The watch page picks <audio> vs <video> from this flag; WebKit refuses an audio-only mp3 in a
<video>, so an audio-only asset (media_kind='audio') must report audio=True. The flag now reads
the stored media_kind (the single source), not a re-derived codec check."""
_link = SimpleNamespace(allow_download=True)
def test_audio_only_asset_is_flagged_audio(self):
asset = _asset(media_kind="audio", vcodec=None, acodec="mp3", width=None, height=None)
meta = public_meta(self._link, None, asset, "/f.mp3")
assert meta["audio"] is True
def test_a_video_asset_is_not_audio(self):
meta = public_meta(self._link, None, _asset(), "/f.mp4")
assert meta["audio"] is False
def test_a_generic_file_is_not_audio(self):
# A non-media upload (media_kind='file') has no player at all — never the audio bar.
meta = public_meta(self._link, None, _asset(media_kind="file"), "/f.bin")
assert meta["audio"] is False
assert meta["media_kind"] == "file"
class TestCreateLinkDefault:
"""D1 (E7 S3): a newly minted share link is downloadable by default. The one-click Share relies on
this — it POSTs no `allow_download`, so the API default decides. Forward-only lives here (the API
default), NOT in a migration: the DownloadLink column default stays False, so no existing back-
catalogue link silently flips to downloadable."""
def test_omitted_allow_download_defaults_downloadable(self):
from app.routes.downloads import CreateLinkIn
assert CreateLinkIn().allow_download is True
def test_explicit_false_is_respected(self):
# The owner can still uncheck it to mint a stream-only link.
from app.routes.downloads import CreateLinkIn
assert CreateLinkIn(allow_download=False).allow_download is False
class TestIsExpired:
def test_none_expiry_never_expires(self):
assert is_expired(SimpleNamespace(expires_at=None)) is False
def test_a_future_expiry_is_live(self):
future = datetime.now(timezone.utc) + timedelta(hours=1)
assert is_expired(SimpleNamespace(expires_at=future)) is False
def test_a_past_expiry_is_expired(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
assert is_expired(SimpleNamespace(expires_at=past)) is True