fix(files): ending every session ends every API token, and the share links hold their folder
- A password reset or change, "log out everywhere" and a revoked untrusted password revoke all of the account's API tokens too (audited): one minted from a stolen session no longer outlives the answer. - A bearer token or link grant with a non-ASCII character is refused (401/403), not a 500. - Making a token's grants or a folder link holds the folder, so a racing delete cannot fail the foreign key; a link's view count is one atomic UPDATE. - A folder link's card counts exactly the files its page lists (one `links.folder_files`). - A file with no extension is served without a trailing dot. - 0106's downgrade names a Hub folder so it never collides with one of the user's folders. - The speed baseline reads share tokens through the app (the database keeps them hashed/encrypted). - Tests: the token scope on the real session stack, the folder poster, the Hub refused for a link, 0106's downgrade data step, 0107 without a key; docs/comments follow (three folder kinds, folder links, key change, the cleanup job's token warnings).
This commit is contained in:
@@ -7,6 +7,7 @@ a file link; a document is never served to open as a page.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
@@ -15,9 +16,9 @@ from sqlalchemy import select, text
|
||||
|
||||
from app.config import settings
|
||||
from app.db import engine
|
||||
from app.downloads import og
|
||||
from app.downloads import folders, og
|
||||
from app.downloads import probe as probemod
|
||||
from app.models import AuditLog, DownloadJob, DownloadLink, FileFolder
|
||||
from app.models import AuditLog, DownloadJob, DownloadLink, FileFolder, MediaAsset
|
||||
|
||||
_VIDEO = {
|
||||
"streams": [
|
||||
@@ -125,6 +126,12 @@ def test_only_a_folder_of_ones_own_can_be_shared(shelf, db, as_user, make_user):
|
||||
r = owner.post(f"/api/downloads/folders/{source}/links", json={})
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"]["reason"] == "share_folder_kind"
|
||||
# Nor the Hub: whatever a machine drops there would be public the moment it lands.
|
||||
hub = folders.hub_folder(db, shelf["user"].id).id
|
||||
db.commit()
|
||||
r = owner.post(f"/api/downloads/folders/{hub}/links", json={})
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"]["reason"] == "share_folder_kind"
|
||||
|
||||
stranger = as_user(make_user())
|
||||
assert (
|
||||
@@ -154,6 +161,43 @@ def test_a_password_folder_link_shows_nothing_until_unlocked(shelf, client):
|
||||
assert client.get(files[0]["file_url"]).status_code == 200
|
||||
|
||||
|
||||
def test_a_files_poster_opens_by_the_links_own_rules(shelf, db, client):
|
||||
"""The poster route on its own: a password link's poster needs the unlock grant, and a file
|
||||
outside the folder has none through it, grant or not."""
|
||||
for job_id in (shelf["inside"][0], shelf["outside"]):
|
||||
asset = db.get(MediaAsset, db.get(DownloadJob, job_id).asset_id)
|
||||
asset.poster_path = f"poster-{job_id}.jpg"
|
||||
(Path(settings.files_root) / asset.poster_path).write_bytes(b"a poster frame")
|
||||
db.commit()
|
||||
token = _link(shelf, password="open sesame")["token"]
|
||||
inside = f"/api/public/watch/{token}/files/{shelf['inside'][0]}/poster.jpg"
|
||||
assert client.get(inside).status_code == 403
|
||||
|
||||
unlocked = client.post(f"/api/public/watch/{token}/unlock", json={"password": "open sesame"})
|
||||
[entry] = [f for f in unlocked.json()["files"] if f["id"] == shelf["inside"][0]]
|
||||
assert entry["poster_url"].startswith(inside + "?g=")
|
||||
got = client.get(entry["poster_url"])
|
||||
assert got.status_code == 200 and got.content == b"a poster frame"
|
||||
grant = entry["poster_url"].split("?g=", 1)[1]
|
||||
outside = f"/api/public/watch/{token}/files/{shelf['outside']}/poster.jpg?g={grant}"
|
||||
assert client.get(outside).status_code == 404
|
||||
|
||||
|
||||
def test_every_open_counts_as_a_view(shelf, client):
|
||||
made = _link(shelf)
|
||||
for _ in range(3):
|
||||
assert client.get(f"/api/public/watch/{made['token']}").status_code == 200
|
||||
[listed] = shelf["owner"].get(f"/api/downloads/folders/{shelf['folder']}/links").json()
|
||||
assert listed["view_count"] == 3
|
||||
|
||||
|
||||
def test_the_folder_list_is_capped_like_the_owners_files_list():
|
||||
from app.downloads import links as linksmod
|
||||
from app.routes.files import MAX_LIST
|
||||
|
||||
assert linksmod.FOLDER_MAX == MAX_LIST
|
||||
|
||||
|
||||
def test_a_document_in_a_shared_folder_only_downloads(shelf, client, probe):
|
||||
probe["data"] = None
|
||||
doc = shelf["upload"]("notes.html", shelf["folder"], b"<script>1</script>")
|
||||
@@ -234,6 +278,11 @@ def test_the_unfurl_names_the_folder_and_counts_its_files_only(shelf, db):
|
||||
for name in names:
|
||||
assert not name or name not in tags
|
||||
|
||||
# A file whose bytes are gone is off the page, and so off the card's count.
|
||||
gone = db.get(MediaAsset, db.get(DownloadJob, shelf["inside"][0]).asset_id)
|
||||
(Path(settings.files_root) / gone.rel_path).unlink()
|
||||
assert "1 file " in og._watch_tags(token, db)
|
||||
|
||||
locked = _link(shelf, password="pw")["token"]
|
||||
hidden = og._watch_tags(locked, db)
|
||||
assert "Friends" not in hidden and "Password-protected folder" in hidden
|
||||
|
||||
Reference in New Issue
Block a user