The backend had zero tests. This adds 33, all pure (no DB, no network), plus the harness to run them in the gate: - backend/Dockerfile.test: the app's deps + pinned pytest/ruff, code bind-mounted at run time so it always tests the working tree. Isolated from the prod Dockerfile, so the published image never carries test tooling. - requirements-dev.txt: pytest==8.4.2, ruff==0.15.21 (the version the gate already runs) — the backend lane is now pinned, not just host-global. - tests: normalize_title (de-shout, hashtag strip, emoji drop, trilingual letters survive); storage sanitize/rel_path/download_filename and the safe_abs_path traversal guard (../ and absolute-path escapes rejected — verified by mutation); links HMAC grants (round-trip, wrong-token, tampered sig/exp, expiry) + is_expired; a DB-free app-assembly smoke (every router wires up, OpenAPI generates). DB-backed router smoke (auth/feed/downloads) needs a test Postgres + migrations — deferred. useCardPager needs hook-test infra (jsdom/renderHook) — deferred.
54 lines
2.2 KiB
Python
54 lines
2.2 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
|
|
|
|
|
|
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")
|
|
assert check_grant("tok", grant) 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")) is False
|
|
|
|
def test_a_tampered_signature_is_rejected(self):
|
|
exp, _sig = make_grant("tok").split(".", 1)
|
|
assert check_grant("tok", f"{exp}.deadbeef") is False
|
|
|
|
def test_a_tampered_expiry_is_rejected(self):
|
|
# Extending the exp field invalidates the signature (which covers token.exp).
|
|
_exp, sig = make_grant("tok").split(".", 1)
|
|
future = int(datetime.now(timezone.utc).timestamp()) + 10 * _GRANT_TTL
|
|
assert check_grant("tok", f"{future}.{sig}") 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", now=past)) is False
|
|
|
|
def test_malformed_grants_are_rejected_not_crashed(self):
|
|
for bad in [None, "", "no-dot", "notanumber.sig"]:
|
|
assert check_grant("tok", bad) 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
|