Files
siftlode/backend/tests/test_api_tokens.py
T
peter b2d34e6e6c feat(files): API tokens — a machine reaches the library folder by folder
A user can make a token for a machine of theirs (`/api/tokens`, signed-in only): `sfl_` + 32
random bytes + a checksum, only its SHA-256 kept, shown once. Sent as a Bearer header it is
honoured on six `/api/files` routes only — folders, list, file, hash, upload, delete — and there
only in the folders its grants name, with the rights they give (list / read / write / delete).
One check, `FilesCaller.may`, in every route; a folder or file out of reach answers like a
missing one; the files shared with the user are never in reach. A token beats a cookie on the
same request.

The Hub preset makes the user's one Hub folder (a new folder kind) and grants it every right.
Refused tokens are slowed down per client, a runaway machine per token; last use is stamped at
most once a minute; making and revoking a token is audited, and a token's uploads and deletes
name it in their audit rows.
2026-10-14 18:40:23 +02:00

379 lines
15 KiB
Python

"""API tokens for the Files library (`app.apitokens`, `routes.tokens`), DB-lane.
A token is made, listed and revoked by its signed-in user only; its value is in the answer that made
it and nowhere after. With it a machine reaches `/api/files` and nothing else, and there only the
folders its grants name, with the rights they give — every right checked in a folder it has and in
one it has not. A token that is malformed, unknown, expired, revoked, or whose account is suspended
holds nothing, and a client trying tokens is slowed down.
"""
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app import apitokens
from app.apitokens import files_caller
from app.downloads import folders, service
from app.downloads import upload as uploadmod
from app.downloads.status import JobStatus
from app.config import settings
from app.main import app
from app.models import ApiToken, ApiTokenGrant, AuditLog, DownloadJob, FileFolder, MediaAsset
from app.ratelimit import RateLimiter
from app.routes import tokens as tokens_routes
@pytest.fixture
def _root(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "files_root", str(tmp_path))
return tmp_path
@pytest.fixture
def _no_probe(monkeypatch):
# A file ffprobe cannot read: a generic file, done at once (no conversion).
monkeypatch.setattr(uploadmod.probe, "probe_json", lambda _p: None)
def _own(db, user_id, name):
folder = FileFolder(user_id=user_id, kind=folders.FolderKind.OWN, name=name)
db.add(folder)
db.commit()
return folder
def _file_in(db, root, user_id, folder, name):
"""A done file of `user_id`'s with bytes on disk, sitting in `folder`."""
asset = MediaAsset(
source_kind="upload",
source_ref=name,
format_sig=f"sig-{name}",
status="ready",
rel_path=f"{name}.bin",
size_bytes=4,
ref_count=0,
title=name,
media_kind="file",
container="bin",
)
db.add(asset)
db.flush()
job = DownloadJob(
user_id=user_id,
asset_id=asset.id,
source_kind="upload",
source_ref=name,
profile_snapshot={},
status=JobStatus.DONE,
)
db.add(job)
db.flush()
folders.place(db, job)
job.folder_id = folder.id
folders.stamp_done(db, job)
service.incr_ref_count(db, asset.id)
db.commit()
(root / f"{name}.bin").write_bytes(b"data")
return job
def _bearer(raw: str) -> TestClient:
return TestClient(app, headers={"authorization": f"Bearer {raw}"})
def _make(c, **body) -> dict:
r = c.post("/api/tokens", json={"name": "a machine", **body})
assert r.status_code == 200, r.text
return r.json()
def _audits(db, action):
return db.execute(select(AuditLog).where(AuditLog.action == action)).scalars().all()
# --- the token itself ----------------------------------------------------------------------------
def test_a_token_has_its_shape_and_a_checksum_that_refuses_a_typo():
raw = apitokens.mint()
assert raw.startswith("sfl_") and len(raw) == apitokens.TOKEN_LEN
assert apitokens.well_formed(raw)
assert apitokens.mint() != raw
flipped = raw[:10] + ("A" if raw[10] != "A" else "B") + raw[11:]
assert not apitokens.well_formed(flipped)
assert not apitokens.well_formed(raw[:-1])
assert not apitokens.well_formed("xxx_" + raw[4:])
def test_the_hub_preset_makes_one_hub_folder_with_every_right_and_shows_the_token_once(
db, make_user, as_user
):
alice = make_user()
c = as_user(alice)
made = _make(c, preset="hub")
raw = made["token"]
assert apitokens.well_formed(raw) and made["prefix"] == raw[: apitokens.SHOWN_LEN]
[grant] = made["grants"]
assert grant["kind"] == "hub" and grant["rights"] == ["delete", "list", "read", "write"]
# Only the hash is kept, and the value is not in the list.
stored = db.execute(select(ApiToken)).scalar_one()
assert stored.token_hash != raw and raw not in str(c.get("/api/tokens").json())
# A second hub token reuses the one Hub folder.
again = _make(c, preset="hub")
assert again["grants"][0]["folder_id"] == grant["folder_id"]
hubs = db.execute(select(FileFolder).where(FileFolder.kind == "hub")).scalars().all()
assert len(hubs) == 1
assert len(_audits(db, "api_token_create")) == 2
def test_a_token_is_granted_only_the_users_own_folders_and_writes_only_where_uploads_land(
db, make_user, as_user
):
alice, bob = make_user(), make_user()
theirs = _own(db, bob.id, "Bob's")
c = as_user(alice)
r = c.post(
"/api/tokens",
json={"name": "x", "grants": [{"folder_id": theirs.id, "rights": ["read"]}]},
)
assert r.status_code == 409 and r.json()["detail"]["reason"] == "file_folder_gone"
youtube = folders.source_folder(db, alice.id, folders.SourceKey.YOUTUBE)
db.commit()
r = c.post(
"/api/tokens",
json={"name": "x", "grants": [{"folder_id": youtube.id, "rights": ["write"]}]},
)
assert r.json()["detail"]["reason"] == "file_folder_not_its_source"
assert c.post("/api/tokens", json={"name": "x"}).json()["detail"]["reason"] == (
"api_token_no_grants"
)
assert db.execute(select(ApiToken)).all() == []
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())
_make(c, preset="hub")
_make(c, preset="hub")
r = c.post("/api/tokens", json={"name": "third", "preset": "hub"})
assert r.status_code == 409 and r.json()["detail"]["reason"] == "api_token_limit"
# --- what a token reaches ------------------------------------------------------------------------
@pytest.mark.parametrize("right", ["list", "read", "write", "delete"])
def test_each_right_holds_in_its_folder_only(
db, make_user, as_user, _root, _no_probe, upload_headers, right
):
"""One right in folder A; folder B has none. Every Files route a token may use, in both."""
alice = make_user()
a, b = _own(db, alice.id, "A"), _own(db, alice.id, "B")
in_a, in_b = _file_in(db, _root, alice.id, a, "fa"), _file_in(db, _root, alice.id, b, "fb")
raw = _make(as_user(alice), grants=[{"folder_id": a.id, "rights": [right]}])["token"]
m = _bearer(raw)
folders_seen = [f["id"] for f in m.get("/api/files/folders").json()]
assert folders_seen == ([a.id] if right == "list" else [])
listed = m.get("/api/files", params={"folder_id": a.id})
assert listed.status_code == (200 if right == "list" else 404)
if right == "list":
assert [f["id"] for f in listed.json()] == [in_a.id]
assert [f["id"] for f in m.get("/api/files").json()] == [in_a.id]
else:
assert m.get("/api/files").json() == []
assert m.get("/api/files", params={"folder_id": b.id}).status_code == 404
read_a = m.get(f"/api/files/{in_a.id}/file")
assert read_a.status_code == (200 if right == "read" else 404)
assert m.get(f"/api/files/{in_a.id}/sha256").status_code == (200 if right == "read" else 404)
assert m.get(f"/api/files/{in_b.id}/file").status_code == 404
up_a = m.post("/api/files/upload", content=b"hi", headers=upload_headers("n.txt", folder=a.id))
assert up_a.status_code == (200 if right == "write" else 404)
if right == "write":
assert up_a.json()["folder_id"] == a.id
up_b = m.post("/api/files/upload", content=b"hi", headers=upload_headers("n.txt", folder=b.id))
assert up_b.status_code == 404
no_folder = m.post("/api/files/upload", content=b"hi", headers=upload_headers("n.txt"))
assert no_folder.status_code == 400
assert no_folder.json()["detail"]["reason"] == "api_token_folder_required"
assert m.delete(f"/api/files/{in_b.id}").status_code == 404
assert db.get(DownloadJob, in_b.id) is not None
del_a = m.delete(f"/api/files/{in_a.id}")
assert del_a.status_code == (200 if right == "delete" else 404)
db.expire_all()
assert (db.get(DownloadJob, in_a.id) is None) == (right == "delete")
def test_a_token_never_reaches_another_users_files_or_the_shared_ones(
db, make_user, as_user, _root
):
alice, bob = make_user(), make_user()
hub_raw = _make(as_user(alice), preset="hub")["token"]
bobs = _file_in(db, _root, bob.id, _own(db, bob.id, "B"), "bf")
m = _bearer(hub_raw)
assert m.get(f"/api/files/{bobs.id}/file").status_code == 404
assert m.delete(f"/api/files/{bobs.id}").status_code == 404
shared = m.get("/api/files", params={"scope": "shared"})
assert shared.status_code == 403 and shared.json()["detail"]["reason"] == "api_token_scope"
def test_a_token_is_judged_by_its_grants_even_beside_a_signed_in_cookie(db, make_user, client):
"""Through the real session stack (`client`, a real sign-in): the `as_user` fixture stands in
for the very dependency this is about."""
alice = make_user(email="alice@example.com")
a = _own(db, alice.id, "A")
c = client
signed_in = c.post(
"/auth/password-login",
json={"email": "alice@example.com", "password": "correct horse battery"},
)
assert signed_in.status_code == 200
raw = _make(c, grants=[{"folder_id": a.id, "rights": ["read"]}])["token"]
assert len(c.get("/api/files/folders").json()) == 1 # the user sees every folder
both = c.get("/api/files/folders", headers={"authorization": f"Bearer {raw}"})
assert both.json() == [] # the token may not list: its grants decide
def test_a_token_works_on_the_files_routes_only(db, make_user, as_user):
alice = make_user()
raw = _make(as_user(alice), preset="hub")["token"]
m = _bearer(raw)
assert m.get("/api/files/folders").status_code == 200
# Making a wider token, or reading the account, takes the user's session.
assert m.get("/api/tokens").status_code == 401
assert m.post("/api/tokens", json={"name": "wider", "preset": "hub"}).status_code == 401
assert m.get("/api/files/usage").status_code == 401
assert m.patch("/api/files/1", json={"name": "x"}).status_code == 401
def test_only_the_files_routes_take_a_token():
"""The route table, not a list kept by hand: every route that resolves `files_caller` is under
/api/files, and these are all of them. The walk recurses into included routers (see
`test_body_limit._declared_routes` for why `app.routes` alone sees none of ours)."""
takers, nodes, routes = set(), [app], []
while nodes:
for route in getattr(nodes.pop(), "routes", ()) or ():
inner = getattr(route, "original_router", None)
if inner is not None:
nodes.append(inner)
else:
routes.append(route)
assert len(routes) > 100 # the walk found the app's routes, not just the shell's
for route in routes:
dependant = getattr(route, "dependant", None)
if dependant is None:
continue
stack, calls = [dependant], set()
while stack:
d = stack.pop()
calls.add(d.call)
stack.extend(d.dependencies)
if files_caller in calls:
for method in route.methods:
takers.add((method, route.path))
assert all(path.startswith("/api/files") for _, path in takers)
assert takers == {
("GET", "/api/files/folders"),
("GET", "/api/files"),
("GET", "/api/files/{job_id}/file"),
("GET", "/api/files/{job_id}/sha256"),
("POST", "/api/files/upload"),
("DELETE", "/api/files/{job_id}"),
}
# --- a token that does not hold ------------------------------------------------------------------
def _refused(m: TestClient) -> int:
return m.get("/api/files/folders").status_code
def test_a_malformed_unknown_expired_revoked_or_suspended_token_holds_nothing(
db, make_user, as_user
):
alice = make_user()
c = as_user(alice)
raw = _make(c, preset="hub")["token"]
assert _refused(_bearer(raw)) == 200
assert _refused(_bearer("sfl_short")) == 401
assert _refused(_bearer(apitokens.mint())) == 401 # well formed, never made
expiring = _make(c, preset="hub", expires_days=1)
token = db.get(ApiToken, expiring["id"])
token.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
db.commit()
assert _refused(_bearer(expiring["token"])) == 401
assert c.get("/api/tokens").json()[1]["expired"] is True
assert c.delete(f"/api/tokens/{expiring['id']}").json() == {
"ok": True,
"deleted": expiring["id"],
}
assert (
db.execute(select(ApiTokenGrant).where(ApiTokenGrant.token_id == expiring["id"])).all()
== []
)
assert len(_audits(db, "api_token_revoke")) == 1
revoked = _make(c, preset="hub")
assert c.delete(f"/api/tokens/{revoked['id']}").status_code == 200
assert _refused(_bearer(revoked["token"])) == 401
alice.is_suspended = True
db.commit()
assert _refused(_bearer(raw)) == 401
def test_another_users_token_cannot_be_revoked(db, make_user, as_user):
alice, bob = make_user(), make_user()
made = _make(as_user(alice), preset="hub")
assert as_user(bob).delete(f"/api/tokens/{made['id']}").status_code == 404
assert db.get(ApiToken, made["id"]) is not None
def test_a_client_trying_tokens_is_slowed_down(db, make_user, as_user, monkeypatch):
alice = make_user()
raw = _make(as_user(alice), preset="hub")["token"]
monkeypatch.setattr(apitokens, "_bad", RateLimiter(3, 300))
for _ in range(3):
assert _refused(_bearer(apitokens.mint())) == 401
slowed = _bearer(apitokens.mint()).get("/api/files/folders")
assert slowed.status_code == 429
assert slowed.json()["detail"]["reason"] == "api_token_rate_limited"
# Even a good token from that client waits out the window: a guesser cannot tell a hit.
assert _refused(_bearer(raw)) == 429
def test_a_runaway_machine_is_held_back_per_token(db, make_user, as_user, monkeypatch):
c = as_user(make_user())
first, second = _make(c, preset="hub")["token"], _make(c, preset="hub")["token"]
monkeypatch.setattr(apitokens, "_calls", RateLimiter(2, 60))
assert [_refused(_bearer(first)) for _ in range(3)] == [200, 200, 429]
assert _refused(_bearer(second)) == 200
# --- what a token leaves behind ------------------------------------------------------------------
def test_a_tokens_use_is_stamped_and_its_uploads_and_deletes_name_it_in_the_audit(
db, make_user, as_user, _root, _no_probe, upload_headers
):
alice = make_user()
made = _make(as_user(alice), preset="hub")
hub = made["grants"][0]["folder_id"]
assert db.get(ApiToken, made["id"]).last_used_at is None
m = _bearer(made["token"])
up = m.post("/api/files/upload", content=b"hi", headers=upload_headers("h.txt", folder=hub))
assert up.status_code == 200
db.expire_all()
assert db.get(ApiToken, made["id"]).last_used_at is not None
assert m.delete(f"/api/files/{up.json()['id']}").status_code == 200
via = f"api token {made['prefix']}…"
assert [a.summary for a in _audits(db, "file_upload")] == [via]
assert [a.summary for a in _audits(db, "file_delete")] == [via]