Both links out of the dashboard editor were built root-relative, so a portal
serving the app under `/i/{id}` got a URL to itself: the hub has no route
there and answers a bare 404. That is what a device link and "open what a
wall panel sees" both landed on.
They want different answers. The view link is for the person already looking,
so it takes the router's basepath — `appPath` in `lib/portal` is the same
prefix the router applies to every `Link`, for the places that step outside
it. The device link is for a screen, which cannot go through the portal at
all: the shell is served only to a portal session, and the credential that
page carries is the portal's rather than the panel's. So the server now says
where it answers, and `FRONTEND_HOST` is that answer — the same setting the
password-reset links already use.
Also fixes the panel branch in the query error handler, which compared a raw
pathname and so never fired under a portal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHpLJHozysQXjsxAyU1WHj
206 lines
6.8 KiB
Python
206 lines
6.8 KiB
Python
"""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"]
|
|
# The address a device is pointed at comes from the server, because the
|
|
# browser's own origin is the portal's when someone administers remotely.
|
|
assert stored["frontend_host"] == settings.FRONTEND_HOST.rstrip("/")
|
|
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
|
|
)
|