mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
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:
co-authored by
Claude Opus 5.5
parent
71a1962f69
commit
76012dafca
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user