Panels: per-device dashboard sets, paired by code
A panel is one screen and the ordered set of whole dashboards it shows, so a hallway tablet and a workshop tablet carry different sets without either dashboard knowing about the other. More than one and the device draws a rail to switch between them — the same rail the editor puts on screen, because the wall has it and it takes room off the canvas. A screen has no keyboard, so it pairs rather than logs in: it shows a six-character code, somebody approves it against a panel from the dashboards overview, and the credential that mints reaches that panel's published dashboards and the message endpoints its widgets speak, and nothing else. Deleting the panel revokes it. Closes the per-device view and the kiosk credential; supersedes the multi-page/multi-section UI, since a page is now a dashboard of its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AHpLJHozysQXjsxAyU1WHj
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
"""Panels: assignment, pairing, and what a paired credential may reach."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
PREFIX = f"{settings.API_V1_STR}/panels"
|
||||
DASHBOARDS = f"{settings.API_V1_STR}/dashboards"
|
||||
|
||||
|
||||
def _panels(client: TestClient, headers: dict[str, str], config: dict) -> None:
|
||||
response = client.put(f"{PREFIX}/", headers=headers, json=config)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
def _pair(client: TestClient, headers: dict[str, str], panel: str) -> dict[str, str]:
|
||||
"""Walk a device through pairing and return the header it ends up with."""
|
||||
started = client.post(f"{PREFIX}/pair").json()
|
||||
waiting = client.get(
|
||||
f"{PREFIX}/pair/{started['code']}", params={"secret": started["secret"]}
|
||||
)
|
||||
assert waiting.json()["access_token"] is None
|
||||
|
||||
approved = client.post(
|
||||
f"{PREFIX}/{panel}/pair", headers=headers, json={"code": started["code"]}
|
||||
)
|
||||
assert approved.status_code == 200, approved.text
|
||||
|
||||
collected = client.get(
|
||||
f"{PREFIX}/pair/{started['code']}", params={"secret": started["secret"]}
|
||||
).json()
|
||||
assert collected["panel"] == panel
|
||||
return {"Authorization": f"Bearer {collected['access_token']}"}
|
||||
|
||||
|
||||
def test_panels_require_authentication(client: TestClient) -> None:
|
||||
assert client.get(f"{PREFIX}/").status_code == 401
|
||||
assert client.put(f"{PREFIX}/", json={"panels": []}).status_code == 401
|
||||
|
||||
|
||||
def test_assign_and_read_back(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
for name in ("hall_a", "hall_b"):
|
||||
client.post(f"{DASHBOARDS}/{name}", headers=superuser_token_headers)
|
||||
|
||||
_panels(
|
||||
client,
|
||||
superuser_token_headers,
|
||||
{
|
||||
"panels": [
|
||||
{"id": "hall", "title": "Hall", "dashboards": ["hall_a", "hall_b"]}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
stored = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
|
||||
assert stored["panels"][0]["dashboards"] == ["hall_a", "hall_b"]
|
||||
assert (
|
||||
client.get(f"{PREFIX}/hall", headers=superuser_token_headers).json()["title"]
|
||||
== "Hall"
|
||||
)
|
||||
assert (
|
||||
client.get(f"{PREFIX}/nowhere", headers=superuser_token_headers).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
|
||||
def test_duplicate_panel_is_refused(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
response = client.put(
|
||||
f"{PREFIX}/",
|
||||
headers=superuser_token_headers,
|
||||
json={"panels": [{"id": "twice"}, {"id": "twice"}]},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_polling_needs_the_secret(client: TestClient) -> None:
|
||||
started = client.post(f"{PREFIX}/pair").json()
|
||||
assert len(started["code"]) == 6
|
||||
assert (
|
||||
client.get(
|
||||
f"{PREFIX}/pair/{started['code']}", params={"secret": "wrong"}
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
assert (
|
||||
client.get(f"{PREFIX}/pair/ZZZZZZ", params={"secret": "x"}).status_code == 404
|
||||
)
|
||||
|
||||
|
||||
def test_approving_an_unknown_code_or_panel_is_refused(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
_panels(client, superuser_token_headers, {"panels": [{"id": "hall"}]})
|
||||
assert (
|
||||
client.post(
|
||||
f"{PREFIX}/hall/pair",
|
||||
headers=superuser_token_headers,
|
||||
json={"code": "ZZZZZZ"},
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
started = client.post(f"{PREFIX}/pair").json()
|
||||
assert (
|
||||
client.post(
|
||||
f"{PREFIX}/nowhere/pair",
|
||||
headers=superuser_token_headers,
|
||||
json={"code": started["code"]},
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
|
||||
def test_paired_panel_reaches_only_what_it_shows(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
for name in ("panel_shown", "panel_hidden"):
|
||||
client.post(f"{DASHBOARDS}/{name}", headers=superuser_token_headers)
|
||||
_panels(
|
||||
client,
|
||||
superuser_token_headers,
|
||||
{"panels": [{"id": "hall", "dashboards": ["panel_shown"]}]},
|
||||
)
|
||||
|
||||
panel_headers = _pair(client, superuser_token_headers, "hall")
|
||||
|
||||
# What it was assigned, published, plus its own definition and the messages
|
||||
# its widgets speak.
|
||||
assert (
|
||||
client.get(f"{DASHBOARDS}/panel_shown", headers=panel_headers).status_code
|
||||
== 200
|
||||
)
|
||||
assert client.get(f"{PREFIX}/hall", headers=panel_headers).status_code == 200
|
||||
assert (
|
||||
client.get(
|
||||
f"{settings.API_V1_STR}/messages/", headers=panel_headers
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
# The generated client spells the default out, so `?draft=false` is what a
|
||||
# browser actually asks with for "the published one".
|
||||
for spelling in ("false", "0", "off"):
|
||||
assert (
|
||||
client.get(
|
||||
f"{DASHBOARDS}/panel_shown",
|
||||
headers=panel_headers,
|
||||
params={"draft": spelling},
|
||||
).status_code
|
||||
== 200
|
||||
), spelling
|
||||
|
||||
# And nothing else.
|
||||
assert (
|
||||
client.get(f"{DASHBOARDS}/panel_hidden", headers=panel_headers).status_code
|
||||
== 403
|
||||
)
|
||||
# Fail-closed: a spelling neither side recognises counts as a draft.
|
||||
for spelling in ("true", "1", "yes", "maybe"):
|
||||
assert (
|
||||
client.get(
|
||||
f"{DASHBOARDS}/panel_shown",
|
||||
headers=panel_headers,
|
||||
params={"draft": spelling},
|
||||
).status_code
|
||||
== 403
|
||||
), spelling
|
||||
assert client.get(f"{DASHBOARDS}/", headers=panel_headers).status_code == 403
|
||||
assert (
|
||||
client.get(f"{settings.API_V1_STR}/flows/", headers=panel_headers).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
client.delete(f"{DASHBOARDS}/panel_shown", headers=panel_headers).status_code
|
||||
== 403
|
||||
)
|
||||
assert client.get(f"{PREFIX}/", headers=panel_headers).status_code == 403
|
||||
|
||||
|
||||
def test_removing_the_panel_revokes_its_credential(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
client.post(f"{DASHBOARDS}/panel_gone", headers=superuser_token_headers)
|
||||
_panels(
|
||||
client,
|
||||
superuser_token_headers,
|
||||
{"panels": [{"id": "workshop", "dashboards": ["panel_gone"]}]},
|
||||
)
|
||||
panel_headers = _pair(client, superuser_token_headers, "workshop")
|
||||
assert (
|
||||
client.get(f"{DASHBOARDS}/panel_gone", headers=panel_headers).status_code == 200
|
||||
)
|
||||
|
||||
_panels(client, superuser_token_headers, {"panels": []})
|
||||
# 401, not 403: there is nothing left to be forbidden from, and the device
|
||||
# should go back to the pairing screen rather than retry.
|
||||
assert (
|
||||
client.get(f"{DASHBOARDS}/panel_gone", headers=panel_headers).status_code == 401
|
||||
)
|
||||
Reference in New Issue
Block a user