Read play sessions across devices without a token fallback

GET /api/play-sessions no longer narrows to the token's device when
device_id is omitted, so the all_devices flag is dropped. Reading anything
beyond the token's own device now requires devices.read, matching the
saves and device endpoints. GET /api/sync/sessions takes the shared
PageParams and gains offset paging.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Georges-Antoine Assi
2026-09-30 15:45:23 -04:00
co-authored by Claude Opus 5.5
parent 71a1962f69
commit 76012dafca
6 changed files with 79 additions and 46 deletions
+6 -12
View File
@@ -1,7 +1,7 @@
from datetime import datetime
from typing import Annotated
from fastapi import Depends, HTTPException, Query, Request, status
from fastapi import Depends, HTTPException, Request, status
from pydantic import BaseModel, Field, model_validator
from decorators.auth import protected_route
@@ -108,18 +108,12 @@ def get_play_sessions(
device_id: str | None = None,
start_after: datetime | None = None,
end_before: datetime | None = None,
all_devices: Annotated[
bool,
Query(
description=(
"Device-bound tokens: include every device's sessions, not only "
"the token's. Ignored when device_id is set."
)
),
] = False,
) -> list[PlaySessionSchema]:
if not device_id:
device_id = None if all_devices else token_device_id(request)
# A device-bound client may read its own device with roms.user.read alone.
if (
device_id is None or device_id != token_device_id(request)
) and Scope.DEVICES_READ not in request.auth.scopes:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
sessions = db_play_session_handler.get_sessions(
user_id=request.user.id,
rom_id=rom_id,
+6 -4
View File
@@ -1,13 +1,14 @@
import asyncio
from collections import Counter
from datetime import datetime
from typing import Annotated
from fastapi import BackgroundTasks, HTTPException, Request, status
from fastapi import BackgroundTasks, Depends, HTTPException, Request, status
from pydantic import Field, model_validator
from config import TASK_TIMEOUT
from decorators.auth import protected_route
from endpoints.responses.base import BaseModel
from endpoints.responses.base import PAGE_QUERY, BaseModel, PageParams
from endpoints.responses.play_session import (
PlaySessionIngestResponse,
PlaySessionIngestResult,
@@ -507,14 +508,15 @@ def complete_sync_session(
@protected_route(router.get, "/sessions", [Scope.DEVICES_READ])
def get_sync_sessions(
request: Request,
page: Annotated[PageParams, Depends(PAGE_QUERY)],
device_id: str | None = None,
limit: int = 50,
) -> list[SyncSessionSchema]:
"""List sync sessions for the current user."""
sessions = db_sync_session_handler.get_sessions(
user_id=request.user.id,
device_id=device_id,
limit=limit,
limit=page.limit,
offset=page.offset,
)
return [SyncSessionSchema.model_validate(s) for s in sessions]
@@ -221,6 +221,7 @@ class DBSyncSessionsHandler(DBBaseHandler):
device_id: str | None = None,
status: SyncSessionStatus | None = None,
limit: int = 50,
offset: int = 0,
session: Session = INJECTED_SESSION,
) -> Sequence[SyncSession]:
query = select(SyncSession).filter_by(user_id=user_id)
@@ -231,5 +232,9 @@ class DBSyncSessionsHandler(DBBaseHandler):
if status:
query = query.filter_by(status=status)
query = query.order_by(SyncSession.initiated_at.desc()).limit(limit)
query = (
query.order_by(SyncSession.initiated_at.desc(), SyncSession.id.desc())
.limit(limit)
.offset(offset)
)
return session.scalars(query).all()
+1 -1
View File
@@ -677,7 +677,7 @@ class TestBoundTokenInference:
list_resp = client.get(
f"/api/play-sessions?device_id={other.id}",
headers={"Authorization": f"Bearer {creds['access_token']}"},
headers={"Authorization": f"Bearer {access_token}"},
)
assert list_resp.status_code == status.HTTP_200_OK
assert len(list_resp.json()) >= 1
+27 -28
View File
@@ -663,15 +663,26 @@ class TestPlaySessionRomUserUpdates:
class TestPlaySessionQuery:
@pytest.mark.parametrize("all_devices", [None, False, True])
def test_paired_client_all_devices(
@pytest.mark.parametrize(
"scopes, target, expected",
[
("roms.user.read devices.read", None, [0, 1, 2]),
("roms.user.read devices.read", "other", [1]),
("roms.user.read", "own", [0]),
("roms.user.read", None, None),
("roms.user.read", "other", None),
],
)
def test_device_token_reads(
self,
client,
admin_user,
editor_user,
device,
rom,
all_devices,
scopes: str,
target: str | None,
expected: list[int] | None,
):
other_device = db_device_handler.add_device(
Device(id=str(uuid.uuid4()), user_id=admin_user.id, name="Other device")
@@ -696,35 +707,23 @@ class TestPlaySessionQuery:
for hours, (user_id, device_id) in enumerate(owners, start=1)
]
)
_, raw = make_device_token(admin_user, device.id, scopes="roms.user.read")
headers = {"Authorization": f"Bearer {raw}"}
# None leaves the flag off the query so the default is exercised.
flag = {} if all_devices is None else {"all_devices": all_devices}
_, raw = make_device_token(admin_user, device.id, scopes=scopes)
params = {
"own": {"device_id": device.id},
"other": {"device_id": other_device.id},
}
response = client.get(
"/api/play-sessions",
params={"rom_id": rom.id, **flag},
headers=headers,
)
assert response.status_code == status.HTTP_200_OK
expected = sessions[:3] if all_devices else sessions[:1]
assert {s["id"] for s in response.json()} == {s.id for s in expected}
# An explicit device filter still narrows the current user's results.
response = client.get(
"/api/play-sessions",
params={"device_id": other_device.id, **flag},
headers=headers,
)
assert response.status_code == status.HTTP_200_OK
assert [s["id"] for s in response.json()] == [sessions[1].id]
def test_all_devices_requires_read_scope(self, client, admin_user, device):
_, raw = make_device_token(admin_user, device.id, scopes="devices.read")
response = client.get(
"/api/play-sessions?all_devices=true",
params=params.get(target or "", {}),
headers={"Authorization": f"Bearer {raw}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
if expected is None:
assert response.status_code == status.HTTP_403_FORBIDDEN
return
assert response.status_code == status.HTTP_200_OK
assert [s["id"] for s in response.json()] == [sessions[i].id for i in expected]
@pytest.mark.parametrize("params", [{"limit": 0}, {"limit": -1}, {"offset": -1}])
def test_rejects_invalid_paging(
+33
View File
@@ -551,6 +551,39 @@ class TestSyncSessions:
assert len(data) == 1
assert data[0]["device_id"] == dev_a.id
def test_list_sessions_paginates(self, client, access_token: str, admin_user: User):
device = db_device_handler.add_device(
Device(id="session-dev-page", user_id=admin_user.id)
)
created = [
db_sync_session_handler.create_session(
device_id=device.id, user_id=admin_user.id
)
for _ in range(3)
]
headers = {"Authorization": f"Bearer {access_token}"}
first = client.get("/api/sync/sessions?limit=2&offset=0", headers=headers)
rest = client.get("/api/sync/sessions?limit=2&offset=2", headers=headers)
assert first.status_code == status.HTTP_200_OK
assert rest.status_code == status.HTTP_200_OK
ids = [s["id"] for s in first.json() + rest.json()]
assert sorted(ids) == sorted(s.id for s in created)
@pytest.mark.parametrize(
"params", [{"limit": 0}, {"limit": 10_001}, {"offset": -1}]
)
def test_list_sessions_rejects_invalid_paging(
self, client, access_token: str, params: dict[str, int]
):
response = client.get(
"/api/sync/sessions",
params=params,
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
def test_get_session(self, client, access_token: str, admin_user: User):
device = db_device_handler.add_device(
Device(id="session-dev-get", user_id=admin_user.id)