fix(files): a token made as every session ends goes with them, and a file being shared holds its job
This commit is contained in:
@@ -10,16 +10,19 @@ holds nothing, and a client trying tokens is slowed down.
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import apitokens
|
||||
from app.apitokens import files_caller
|
||||
from app.auth import current_user
|
||||
from app.downloads import folders, service
|
||||
from app.downloads import gc as gcmod
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.downloads.status import JobStatus
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.main import app
|
||||
from app.models import (
|
||||
ApiToken,
|
||||
@@ -29,9 +32,11 @@ from app.models import (
|
||||
FileFolder,
|
||||
MediaAsset,
|
||||
Notification,
|
||||
User,
|
||||
)
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.routes import tokens as tokens_routes
|
||||
from app.routes.auth import bump_session_epoch
|
||||
from app.security import hash_token
|
||||
|
||||
|
||||
@@ -173,6 +178,39 @@ def test_a_token_is_granted_only_the_users_own_folders_and_writes_only_where_upl
|
||||
assert db.execute(select(ApiToken)).all() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("right", ["read", "write"])
|
||||
def test_a_folder_granted_is_held_until_the_token_is_in(db, make_user, right):
|
||||
"""A delete of the folder at the same moment waits and takes the new grant with it, rather than
|
||||
failing the grant's foreign key."""
|
||||
import threading
|
||||
|
||||
alice = make_user()
|
||||
fid = _own(db, alice.id, "Mine").id
|
||||
body = tokens_routes.TokenIn(
|
||||
name="x", grants=[tokens_routes.GrantIn(folder_id=fid, rights=[right])]
|
||||
)
|
||||
tokens_routes._wanted_grants(db, alice, body)
|
||||
outcome: list[object] = []
|
||||
|
||||
def delete_it():
|
||||
other = SessionLocal()
|
||||
try:
|
||||
outcome.append(folders.delete_own(other, other.get(FileFolder, fid)))
|
||||
other.commit()
|
||||
except Exception as e: # noqa: BLE001 — the assertion below names what went wrong
|
||||
outcome.append(e)
|
||||
finally:
|
||||
other.close()
|
||||
|
||||
t = threading.Thread(target=delete_it)
|
||||
t.start()
|
||||
t.join(timeout=1)
|
||||
assert t.is_alive() # the delete waits for the token on its way in
|
||||
db.commit()
|
||||
t.join(timeout=10)
|
||||
assert not isinstance(outcome[0], Exception), outcome
|
||||
|
||||
|
||||
def test_a_user_has_at_most_the_ceiling_of_tokens(db, make_user, as_user, monkeypatch):
|
||||
monkeypatch.setattr(tokens_routes, "MAX_TOKENS", 2)
|
||||
c = as_user(make_user())
|
||||
@@ -368,6 +406,60 @@ def test_whatever_ends_every_session_ends_every_token_too(db, make_user, client,
|
||||
assert revoked.actor_id == alice.id and "every session" in (revoked.summary or "")
|
||||
|
||||
|
||||
def test_a_token_asked_for_as_every_session_ends_is_refused(db, make_user, client, override_dep):
|
||||
"""The session was still good when its request came in, and ended before the token was made."""
|
||||
alice = make_user(email="alice@example.com")
|
||||
c = _signed_in(client)
|
||||
|
||||
def ended_meanwhile(request: Request, session=Depends(get_db)):
|
||||
user = current_user(request, session)
|
||||
other = SessionLocal()
|
||||
try:
|
||||
bump_session_epoch(other, other.get(User, alice.id))
|
||||
other.commit()
|
||||
finally:
|
||||
other.close()
|
||||
return user
|
||||
|
||||
override_dep(current_user, ended_meanwhile)
|
||||
r = c.post("/api/tokens", json={"name": "late", "preset": "hub"})
|
||||
assert r.status_code == 401
|
||||
assert db.execute(select(ApiToken.id)).scalars().all() == []
|
||||
|
||||
|
||||
def test_a_token_made_as_every_session_ends_goes_with_them(db, make_user):
|
||||
"""The other order: the token is on its way in when the sessions end."""
|
||||
import threading
|
||||
|
||||
alice = make_user(email="alice@example.com")
|
||||
db.execute(select(User.id).where(User.id == alice.id).with_for_update()) # as create_token
|
||||
raw = apitokens.mint()
|
||||
db.add(ApiToken(user_id=alice.id, name="t", prefix=raw[:12], token_hash=hash_token(raw)))
|
||||
db.flush()
|
||||
outcome: list[object] = []
|
||||
|
||||
def end_sessions():
|
||||
other = SessionLocal()
|
||||
try:
|
||||
bump_session_epoch(other, other.get(User, alice.id))
|
||||
other.commit()
|
||||
outcome.append("done")
|
||||
except Exception as e: # noqa: BLE001 — the assertion below names what went wrong
|
||||
outcome.append(e)
|
||||
finally:
|
||||
other.close()
|
||||
|
||||
t = threading.Thread(target=end_sessions)
|
||||
t.start()
|
||||
t.join(timeout=1)
|
||||
assert t.is_alive() # it waits for the token being made
|
||||
db.commit()
|
||||
t.join(timeout=10)
|
||||
assert outcome == ["done"]
|
||||
db.expire_all()
|
||||
assert db.execute(select(ApiToken.id)).scalars().all() == []
|
||||
|
||||
|
||||
# --- a token that does not hold ------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -325,3 +325,73 @@ def test_a_file_whose_bytes_are_gone_is_not_offered(shelf, client, db):
|
||||
(Path(settings.files_root) / asset.rel_path).unlink()
|
||||
assert _ids(client, token) == [shelf["inside"][1]]
|
||||
assert client.get(f"/api/public/watch/{token}/files/{gone}/file").status_code == 404
|
||||
|
||||
|
||||
def _meanwhile(work):
|
||||
"""Run `work(other_session)` in another thread and session, committed; (thread, outcome)."""
|
||||
import threading
|
||||
|
||||
from app.db import SessionLocal
|
||||
|
||||
outcome: list[object] = []
|
||||
|
||||
def run():
|
||||
other = SessionLocal()
|
||||
try:
|
||||
outcome.append(work(other))
|
||||
other.commit()
|
||||
except Exception as e: # noqa: BLE001 — the assertion below names what went wrong
|
||||
outcome.append(e)
|
||||
finally:
|
||||
other.close()
|
||||
|
||||
t = threading.Thread(target=run)
|
||||
t.start()
|
||||
return t, outcome
|
||||
|
||||
|
||||
def test_two_opens_at_once_count_as_two_views(shelf, db):
|
||||
from app.routes import public
|
||||
|
||||
made = _link(shelf)
|
||||
link = db.get(DownloadLink, made["id"])
|
||||
public._opened(db, link, made["token"]) # this open's count is not committed yet
|
||||
t, outcome = _meanwhile(
|
||||
lambda other: public._opened(other, other.get(DownloadLink, made["id"]), made["token"])
|
||||
)
|
||||
t.join(timeout=1)
|
||||
db.commit()
|
||||
t.join(timeout=10)
|
||||
assert not isinstance(outcome[0], Exception), outcome
|
||||
db.expire_all()
|
||||
assert db.get(DownloadLink, made["id"]).view_count == 2
|
||||
|
||||
|
||||
def test_a_file_shared_as_it_is_deleted_takes_its_new_link_with_it(shelf, db):
|
||||
from app.routes import downloads as routes
|
||||
|
||||
job_id = shelf["outside"]
|
||||
routes._shareable_job(db, shelf["user"], job_id) # held from the check until this commits
|
||||
t, outcome = _meanwhile(lambda other: other.delete(other.get(DownloadJob, job_id)))
|
||||
t.join(timeout=1)
|
||||
assert t.is_alive() # the delete waits for the link on its way in
|
||||
db.add(DownloadLink(job_id=job_id, token_hash="h" * 64, created_by=shelf["user"].id))
|
||||
db.commit()
|
||||
t.join(timeout=10)
|
||||
assert outcome == [None]
|
||||
assert db.execute(select(DownloadLink.id)).scalars().all() == []
|
||||
|
||||
|
||||
def test_a_folder_shared_as_it_is_deleted_takes_its_new_link_with_it(shelf, db):
|
||||
from app.routes import downloads as routes
|
||||
|
||||
fid = shelf["folder"]
|
||||
routes._shareable_folder(db, shelf["user"], fid) # held from the check until this commits
|
||||
t, outcome = _meanwhile(lambda other: folders.delete_own(other, other.get(FileFolder, fid)))
|
||||
t.join(timeout=1)
|
||||
assert t.is_alive() # the delete waits for the link on its way in
|
||||
db.add(DownloadLink(folder_id=fid, token_hash="h" * 64, created_by=shelf["user"].id))
|
||||
db.commit()
|
||||
t.join(timeout=10)
|
||||
assert not isinstance(outcome[0], Exception), outcome
|
||||
assert db.execute(select(DownloadLink.id)).scalars().all() == []
|
||||
|
||||
Reference in New Issue
Block a user