Files
app/backend/tests/api/routes/test_panels.py
T
stroblmeandClaude Opus 5 c3ea884d72 Hold a new dashboard back until someone publishes it
A dashboard went live the moment it was created — an empty document straight to
the panels — while a new flow starts as a draft. It now works the way flows do:
published means `dashboard.json` exists, so every dashboard on every running
installation is already published and nothing needs migrating. Only the ones
created from here on start as drafts.

Mirroring FlowStore turned up a latent 500: discarding the draft of a dashboard
that had never been published unlinked its only file, and the read that followed
raised out of a 200 handler. It answers 400 now, the way a flow does.

Publishing all of them was 2N requests, because a publish has to name the
version it expects and the summaries did not carry one. They do now — and so do
the flow summaries, which had the same defect nobody had written down.

A panel had no way to hear about any of this. A publish, or a change to which
dashboards a panel carries, now puts one event on the bus and the screen
refetches what changed: no reload, so a wall display never blanks or asks for
its credential again. The subtle half is that a socket's message allowlist was
computed once at handshake — a reassigned panel would have fetched its new
document and then shown tiles that never updated.

The panels dialog logged non-superusers out. Every write in it needs a
superuser, not only the checkboxes the report mentioned, so the dialog is
read-only for everyone else. The logout itself was `main.tsx` treating 403 as a
dead session, against the contract deps.py spells out: only a 401 ends a
session, and a 403 now says so rather than silently signing someone out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
2026-08-21 14:32:57 +02:00

457 lines
16 KiB
Python

"""Panels: assignment, pairing, and what a paired credential may reach."""
from fastapi.testclient import TestClient
from app.cloud import config as cloud_config
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 _dashboard(client: TestClient, headers: dict[str, str], name: str) -> dict:
"""Create a dashboard and publish it.
A new one is a draft, and a panel is only ever shown what is published.
"""
created = client.post(f"{DASHBOARDS}/{name}", headers=headers)
assert created.status_code == 200, created.text
published = client.post(
f"{DASHBOARDS}/{name}/publish",
headers=headers,
json={"version": created.json()["version"]},
)
assert published.status_code == 200, published.text
return published.json()
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"):
_dashboard(client, superuser_token_headers, name)
_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:
_dashboard(client, superuser_token_headers, "panel_gone")
_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
)
# --------------------------------------------------------------------------
# A screen that reached the portal but not this installation
# --------------------------------------------------------------------------
def _via_portal() -> dict[str, str]:
"""The header the connector stamps on a request it replayed off the tunnel."""
return {cloud_config.VIA_HEADER: cloud_config.VIA_PORTAL}
def test_the_device_asking_is_named_before_anyone_approves(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""Approving a code adopts whatever holds it, so it is worth a look."""
started = client.post(
f"{PREFIX}/pair",
headers={
"user-agent": "Mozilla/5.0 (X11; CrOS aarch64)",
"x-forwarded-for": "203.0.113.7, 10.0.0.1",
},
).json()
looked = client.get(
f"{PREFIX}/pair/{started['code']}/device", headers=superuser_token_headers
)
assert looked.status_code == 200
assert "CrOS" in looked.json()["device"]
# The first hop, not the proxy that relayed it.
assert "203.0.113.7" in looked.json()["device"]
assert looked.json()["remote"] is False
# Nobody without an account gets to enumerate what is waiting.
assert client.get(f"{PREFIX}/pair/{started['code']}/device").status_code == 401
assert (
client.get(
f"{PREFIX}/pair/ZZZZZZ/device", headers=superuser_token_headers
).status_code
== 404
)
def test_a_remote_device_is_paired_at_the_portal(
client: TestClient,
superuser_token_headers: dict[str, str],
enrolled: object, # noqa: ARG001 (fixture installs the enrolment)
monkeypatch, # type: ignore[no-untyped-def]
) -> None:
"""A device that arrived through the tunnel gets the portal's credential.
It could never present one this installation signed: the portal verifies
what crosses it, and it verifies against its own key.
"""
import httpx
from app.api.routes import panels as panels_route
calls: list[dict[str, object]] = []
def fake_post(url: str, **kwargs: object) -> httpx.Response:
calls.append({"url": url, **kwargs})
return httpx.Response(
200,
json={"access_token": "minted-by-the-portal", "expires_in": 31536000},
request=httpx.Request("POST", url),
)
monkeypatch.setattr(panels_route.httpx, "post", fake_post)
_panels(client, superuser_token_headers, {"panels": [{"id": "hallway"}]})
started = client.post(f"{PREFIX}/pair", headers=_via_portal()).json()
looked = client.get(
f"{PREFIX}/pair/{started['code']}/device", headers=superuser_token_headers
).json()
assert looked["remote"] is True
approved = client.post(
f"{PREFIX}/hallway/pair",
headers=superuser_token_headers,
json={"code": started["code"]},
)
assert approved.status_code == 200, approved.text
assert calls[0]["url"].endswith("/api/v1/panel-tokens/") # type: ignore[union-attr]
assert calls[0]["headers"]["Authorization"] == "Bearer installation-token" # type: ignore[index]
assert calls[0]["json"] == {"panel": "hallway"} # type: ignore[index]
collected = client.get(
f"{PREFIX}/pair/{started['code']}", params={"secret": started["secret"]}
).json()
assert collected["access_token"] == "minted-by-the-portal"
def test_a_remote_device_needs_an_enrolment(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""Unenrolled, there is nowhere to ask — and no token to invent locally."""
_panels(client, superuser_token_headers, {"panels": [{"id": "shed"}]})
started = client.post(f"{PREFIX}/pair", headers=_via_portal()).json()
approved = client.post(
f"{PREFIX}/shed/pair",
headers=superuser_token_headers,
json={"code": started["code"]},
)
assert approved.status_code == 409
def test_a_panel_credential_cannot_move_artifacts(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""The artifact store is a person's or a worker's, not a screen's.
It authenticates for itself rather than through the shared dependency, so
the scope check has to be handed the request there too — without it a panel
would act as whoever approved it.
"""
client.post(f"{DASHBOARDS}/panel_art", headers=superuser_token_headers)
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "hallway", "dashboards": ["panel_art"]}]},
)
panel_headers = _pair(client, superuser_token_headers, "hallway")
assert (
client.put(
f"{settings.API_V1_STR}/artifacts",
headers=panel_headers,
params={"name": "x"},
content=b"hello",
).status_code
== 403
)
def test_a_local_device_cannot_claim_to_be_remote(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""Otherwise it would be handed a credential the whole internet accepts."""
started = client.post(f"{PREFIX}/pair", headers={"x-fluksio-via": "portal"}).json()
looked = client.get(
f"{PREFIX}/pair/{started['code']}/device", headers=superuser_token_headers
)
assert looked.json()["remote"] is False
def test_a_panels_socket_carries_only_what_it_draws(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""A screen may hang anywhere, so it is sent its own values and nothing else.
The handshake has no route for the scope check to judge, so the bound is
applied to what goes out: no node status, no logs, no shape of the graph,
and no value belonging to a dashboard this panel does not show.
"""
from app.api.routes.flows import (
event_for_panel,
panel_scope,
snapshot_payload,
)
saved = _dashboard(client, superuser_token_headers, "panel_socket")
saved["pages"] = [
{
"id": "main",
"title": "Overview",
"sections": [
{
"id": "main",
"widgets": [
{
"id": "w1",
"type": "stat",
"config": {"message": "house.kitchen.temperature"},
}
],
}
],
}
]
written = client.put(
f"{DASHBOARDS}/panel_socket", headers=superuser_token_headers, json=saved
)
assert written.status_code == 200, written.text
# A panel reads the published document, so the draft has to be promoted.
published = client.post(
f"{DASHBOARDS}/panel_socket/publish",
headers=superuser_token_headers,
json={"version": written.json()["version"]},
)
assert published.status_code == 200, published.text
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "hallway", "dashboards": ["panel_socket"]}]},
)
token = _pair(client, superuser_token_headers, "hallway")["Authorization"][7:]
only = panel_scope(token, client.app)
assert only == {"house.kitchen.temperature"}
class _Controller:
def values(self) -> dict[str, dict[str, str]]:
return {
"house.kitchen.temperature": {"value": "21"},
"house.safe.code": {"value": "1234"},
}
payload = snapshot_payload(_Controller(), only) # type: ignore[arg-type]
assert set(payload["values"]) == {"house.kitchen.temperature"}
# Emptied rather than dropped: a hanging screen runs the bundle it was
# paired with, and that bundle reads these keys.
assert payload["logs"] == [] and payload["nodes"] == []
assert payload["issues"] == [] and payload["emits"] == {}
# The stream that follows the snapshot is bounded the same way: a value it
# draws goes out, one it does not stays here, and nothing else travels.
assert event_for_panel(
{"type": "message_value", "name": "house.kitchen.temperature"}, only
)
assert not event_for_panel(
{"type": "message_value", "name": "house.safe.code"}, only
)
assert not event_for_panel({"type": "node_log", "text": "a traceback"}, only)
# Except a dashboard being published: that is how a screen hears the
# document it draws — or the set of them it was given — has moved.
assert event_for_panel({"type": "dashboard_changed", "dashboard": "x"}, only)
# A person's credential is not bounded at all.
assert panel_scope(superuser_token_headers["Authorization"][7:], client.app) is None