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:
+74
-5
@@ -12,6 +12,7 @@ from app.cloud import config as cloud_config
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.core.db import engine
|
||||
from app.flow import panels
|
||||
from app.flow.controller import FlowController
|
||||
from app.flow.dashboards import DashboardStore
|
||||
from app.flow.workers import PythonWorkerPool
|
||||
@@ -31,7 +32,57 @@ SessionDep = Annotated[Session, Depends(get_db)]
|
||||
TokenDep = Annotated[str, Depends(reusable_oauth2)]
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
#: What a `draft` query parameter has to say to mean "no". Pydantic's own false
|
||||
#: literals; anything else is refused rather than guessed at.
|
||||
_NOT_DRAFT = {"false", "0", "off", "f", "n", "no", ""}
|
||||
|
||||
|
||||
def _panel_may(payload: dict[str, Any], request: Request) -> None:
|
||||
"""Refuse anything a wall panel has no business asking for.
|
||||
|
||||
A panel credential names the account that approved the pairing, so without
|
||||
this it would be that person's session hanging on a wall. What a panel
|
||||
genuinely needs is small and worth writing out: the dashboards it was
|
||||
assigned, its own definition, and the message endpoints its widgets speak.
|
||||
|
||||
Publishing a message is in the list because a panel cannot be strictly
|
||||
read-only — a querying chart asks for its window by publishing the request
|
||||
— and a control on a panel is the point of putting one there.
|
||||
"""
|
||||
panel = panels.find(str(payload.get("panel", "")))
|
||||
if panel is None:
|
||||
# Deleting a panel is how its credential is revoked, so a token naming
|
||||
# one that is gone is a token to stop trusting.
|
||||
raise InvalidTokenError("This panel no longer exists")
|
||||
|
||||
api = settings.API_V1_STR
|
||||
path = request.url.path
|
||||
method = request.method
|
||||
|
||||
allowed = False
|
||||
if method == "GET" and path.startswith(f"{api}/dashboards/"):
|
||||
name = path[len(f"{api}/dashboards/") :]
|
||||
# One segment only: a dashboard it shows, published — never a draft,
|
||||
# and never a sibling route like `/publish`. The flag is read the way
|
||||
# the endpoint reads it rather than by presence, because the generated
|
||||
# client spells the default out as `?draft=false` — and it is read
|
||||
# fail-closed, so a spelling neither side recognises is a draft.
|
||||
draft = request.query_params.get("draft")
|
||||
published = draft is None or draft.lower() in _NOT_DRAFT
|
||||
allowed = "/" not in name and name in panel.dashboards and published
|
||||
elif method == "GET" and path == f"{api}/panels/{panel.id}":
|
||||
allowed = True
|
||||
elif method in ("GET", "POST") and path.startswith(f"{api}/messages/"):
|
||||
allowed = True
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="A panel credential cannot do that",
|
||||
)
|
||||
|
||||
|
||||
def decode_token(token: str, request: Request | None = None) -> dict[str, Any]:
|
||||
"""Read a bearer token, whichever channel issued it.
|
||||
|
||||
A browser session is signed with the app's own secret; an agent's token is
|
||||
@@ -40,7 +91,13 @@ def decode_token(token: str) -> dict[str, Any]:
|
||||
rights — the difference is only in who is holding it, which the MCP
|
||||
endpoint checks separately.
|
||||
|
||||
The third branch is the seam a hosted deployment widens: a portal this
|
||||
A paired wall panel's token is signed with the app's secret too, and is
|
||||
told apart from a session by its audience: the session decode above
|
||||
refuses it outright, so the only door it fits is the one ``_panel_may``
|
||||
guards. Called without a request — from the websocket, which has no route
|
||||
to scope — the panel branch checks only that the panel still exists.
|
||||
|
||||
The last branch is the seam a hosted deployment widens: a portal this
|
||||
installation was enrolled with signs tokens with a key pinned at
|
||||
enrolment, and they resolve to the local account that performed it. With
|
||||
no enrolment the branch raises immediately, so an offline installation
|
||||
@@ -53,6 +110,16 @@ def decode_token(token: str) -> dict[str, Any]:
|
||||
return session
|
||||
except InvalidTokenError:
|
||||
pass
|
||||
try:
|
||||
panel_token = security.decode_panel_token(token)
|
||||
except InvalidTokenError:
|
||||
pass
|
||||
else:
|
||||
if request is not None:
|
||||
_panel_may(panel_token, request)
|
||||
elif panels.find(str(panel_token.get("panel", ""))) is None:
|
||||
raise InvalidTokenError("This panel no longer exists")
|
||||
return panel_token
|
||||
try:
|
||||
return security.decode_oauth_token(token)
|
||||
except InvalidTokenError:
|
||||
@@ -74,15 +141,17 @@ def user_from_token(session: Session, token: str) -> User | None:
|
||||
return user
|
||||
|
||||
|
||||
def get_current_user(session: SessionDep, token: TokenDep) -> User:
|
||||
def get_current_user(request: Request, session: SessionDep, token: TokenDep) -> User:
|
||||
"""Resolve the bearer token to its user.
|
||||
|
||||
Every failure here is an authentication failure, so all of them answer 401:
|
||||
a token naming a user who no longer exists is a session to log in again,
|
||||
not a missing resource to report.
|
||||
not a missing resource to report. A panel credential reaching past what it
|
||||
is allowed is the one exception — that is a 403 raised inside
|
||||
``decode_token``, and it travels through here untouched.
|
||||
"""
|
||||
try:
|
||||
token_data = TokenPayload(**decode_token(token))
|
||||
token_data = TokenPayload(**decode_token(token, request))
|
||||
except (InvalidTokenError, ValidationError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.api.routes import (
|
||||
modules,
|
||||
oauth,
|
||||
observability,
|
||||
panels,
|
||||
private,
|
||||
runs,
|
||||
secrets,
|
||||
@@ -28,6 +29,7 @@ api_router.include_router(flows.ws_router)
|
||||
api_router.include_router(secrets.router)
|
||||
api_router.include_router(alerts.router)
|
||||
api_router.include_router(dashboards.router)
|
||||
api_router.include_router(panels.router)
|
||||
api_router.include_router(messages.router)
|
||||
api_router.include_router(modules.router)
|
||||
api_router.include_router(observability.router)
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Panels: which dashboards a device shows, and how it gets a credential.
|
||||
|
||||
A wall tablet has no keyboard, so it cannot log in. It asks for a code instead,
|
||||
shows it on the wall, and somebody with an account types that code into the
|
||||
panels dialog to say which panel the device is. The device polls, collects the
|
||||
credential the approval minted, and never asks again.
|
||||
|
||||
The credential is scoped: ``app.api.deps`` lets it reach the dashboards that
|
||||
panel was assigned and nothing else. Removing the panel revokes it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.deps import CurrentUser, get_current_active_superuser, get_current_user
|
||||
from app.core import security
|
||||
from app.flow.panels import PanelDef, PanelsConfig, find, read_config, write_config
|
||||
from app.models import Message
|
||||
|
||||
#: Gated per route rather than on the router: the two pairing endpoints are the
|
||||
#: only unauthenticated ones in the app, because a device with no credential is
|
||||
#: the whole point of them.
|
||||
router = APIRouter(prefix="/panels", tags=["panels"])
|
||||
|
||||
#: Long, because a panel is a screen somebody hung once and left running, with
|
||||
#: no keyboard to sign in again with. Same reasoning as a remote worker's.
|
||||
TOKEN_DAYS = 365
|
||||
|
||||
#: How long a device's code is worth typing. Long enough to walk to a computer.
|
||||
PAIR_TTL = 600
|
||||
|
||||
#: No I/O/0/1: the code is read off a wall and typed on a keyboard.
|
||||
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
CODE_LENGTH = 6
|
||||
|
||||
#: Refuse to grow past this. Codes are free to ask for and cost memory.
|
||||
MAX_PENDING = 50
|
||||
|
||||
|
||||
class _Pending:
|
||||
"""A device waiting to be told what it is."""
|
||||
|
||||
def __init__(self, secret_value: str) -> None:
|
||||
self.secret = secret_value
|
||||
self.expires = time.monotonic() + PAIR_TTL
|
||||
self.token: str = ""
|
||||
self.panel: str = ""
|
||||
|
||||
|
||||
# ponytail: in-process, so pairing needs the API to be one process — which it
|
||||
# is. Move to a table if it ever runs behind more than one worker.
|
||||
_pending: dict[str, _Pending] = {}
|
||||
|
||||
|
||||
def _prune() -> None:
|
||||
now = time.monotonic()
|
||||
for code in [c for c, p in _pending.items() if p.expires < now]:
|
||||
del _pending[code]
|
||||
|
||||
|
||||
class PairStarted(BaseModel):
|
||||
"""What the device puts on the wall, and what it polls with."""
|
||||
|
||||
code: str
|
||||
#: Held by the device, never shown. Without it the code alone would let
|
||||
#: anyone who reads it off the wall collect the credential first.
|
||||
secret: str
|
||||
expires_in: int = PAIR_TTL
|
||||
|
||||
|
||||
class PairStatus(BaseModel):
|
||||
"""Nothing yet, or the credential and the panel it is for."""
|
||||
|
||||
access_token: str | None = None
|
||||
panel: str | None = None
|
||||
|
||||
|
||||
class PairRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
@router.get("/", response_model=PanelsConfig, dependencies=[Depends(get_current_user)])
|
||||
async def read_panels() -> Any:
|
||||
"""Every panel, and what each one shows."""
|
||||
return await run_in_threadpool(read_config)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/",
|
||||
response_model=PanelsConfig,
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
)
|
||||
async def save_panels(body: PanelsConfig) -> Any:
|
||||
"""Replace the panels. Takes effect on the devices' next read.
|
||||
|
||||
A panel that disappears here takes its credential with it, so this is also
|
||||
how a device is unpaired.
|
||||
"""
|
||||
seen = set()
|
||||
for panel in body.panels:
|
||||
if panel.id in seen:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"Two panels named {panel.id!r}"
|
||||
)
|
||||
seen.add(panel.id)
|
||||
|
||||
await run_in_threadpool(write_config, body)
|
||||
return body
|
||||
|
||||
|
||||
@router.post("/pair", response_model=PairStarted)
|
||||
def start_pairing() -> Any:
|
||||
"""A device asks to be adopted. Unauthenticated, by necessity.
|
||||
|
||||
All this hands out is a code that means nothing until somebody with an
|
||||
account approves it, so the worst an unwelcome caller achieves is a line in
|
||||
a dictionary that expires ten minutes later.
|
||||
"""
|
||||
_prune()
|
||||
if len(_pending) >= MAX_PENDING:
|
||||
raise HTTPException(
|
||||
status_code=429, detail="Too many devices are waiting to be paired"
|
||||
)
|
||||
|
||||
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH))
|
||||
while code in _pending:
|
||||
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH))
|
||||
|
||||
entry = _Pending(secrets.token_urlsafe(16))
|
||||
_pending[code] = entry
|
||||
return PairStarted(code=code, secret=entry.secret)
|
||||
|
||||
|
||||
@router.get("/pair/{code}", response_model=PairStatus)
|
||||
def poll_pairing(code: str, secret: str = "") -> Any:
|
||||
"""Has anyone claimed this device yet?
|
||||
|
||||
Answers the same 404 for a code that never existed, one that expired and
|
||||
one polled with the wrong secret: a caller reading a code off a wall learns
|
||||
nothing by guessing.
|
||||
"""
|
||||
_prune()
|
||||
entry = _pending.get(code)
|
||||
if entry is None or not secrets.compare_digest(entry.secret, secret):
|
||||
raise HTTPException(
|
||||
status_code=404, detail="No pairing is waiting on that code"
|
||||
)
|
||||
if not entry.token:
|
||||
return PairStatus()
|
||||
|
||||
# Handed over once. A credential left lying in memory is a second copy of
|
||||
# it, and the device has the only one it needs.
|
||||
del _pending[code]
|
||||
return PairStatus(access_token=entry.token, panel=entry.panel)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{panel_id}/pair",
|
||||
response_model=Message,
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
)
|
||||
def approve_pairing(panel_id: str, body: PairRequest, current_user: CurrentUser) -> Any:
|
||||
"""Say which panel the device showing this code is.
|
||||
|
||||
The credential names the approver, so what the panel does stays
|
||||
attributable to a person rather than to nobody.
|
||||
"""
|
||||
_prune()
|
||||
if find(panel_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"No panel named {panel_id!r}")
|
||||
|
||||
entry = _pending.get(body.code.strip().upper())
|
||||
if entry is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No device is waiting on that code — check it again",
|
||||
)
|
||||
|
||||
entry.token = security.create_panel_token(
|
||||
panel_id, current_user.id, timedelta(days=TOKEN_DAYS)
|
||||
)
|
||||
entry.panel = panel_id
|
||||
return Message(message=f"Paired with {panel_id}")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{panel_id}", response_model=PanelDef, dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
async def read_panel(panel_id: str) -> Any:
|
||||
"""One panel — read by a person, or by the device holding its credential.
|
||||
|
||||
Declared after the pairing routes so a device polling ``/panels/pair/...``
|
||||
is never mistaken for someone reading a panel named ``pair``.
|
||||
"""
|
||||
panel = await run_in_threadpool(find, panel_id)
|
||||
if panel is None:
|
||||
raise HTTPException(status_code=404, detail=f"No panel named {panel_id!r}")
|
||||
return panel
|
||||
@@ -44,6 +44,10 @@ class Settings(BaseSettings):
|
||||
# Which failures reach which channel. Beside the flows, not in them:
|
||||
# alerting is the deployment's concern, not any one flow's.
|
||||
ALERTS_FILE: Path = Path("flow-data/alerts.json")
|
||||
# Which dashboards each device shows. Beside the flows for the same reason
|
||||
# alerting is: where a screen hangs is the deployment's concern rather than
|
||||
# any one dashboard's.
|
||||
PANELS_FILE: Path = Path("flow-data/panels.json")
|
||||
# The MCP endpoint, and the OAuth server agents authenticate against. Off
|
||||
# until someone asks for it: it opens client registration to the network.
|
||||
MCP_ENABLED: bool = False
|
||||
|
||||
@@ -33,6 +33,10 @@ MCP_SCOPE = "mcp"
|
||||
#: agent's token cannot attach a worker and a worker's cannot call the API.
|
||||
WORKER_SCOPE = "worker"
|
||||
WORKER_AUDIENCE = "fluksio-worker"
|
||||
#: What a paired wall panel's credential says it is for. Its own audience, so
|
||||
#: the session decode refuses it outright and the scope check in ``deps`` is
|
||||
#: the only door it fits.
|
||||
PANEL_AUDIENCE = "fluksio-panel"
|
||||
|
||||
|
||||
def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
|
||||
@@ -169,6 +173,43 @@ def decode_worker_token(token: str) -> dict[str, Any]:
|
||||
return payload
|
||||
|
||||
|
||||
def create_panel_token(
|
||||
panel: str, user_id: uuid.UUID | str, expires_delta: timedelta
|
||||
) -> str:
|
||||
"""The credential a paired wall panel holds.
|
||||
|
||||
Signed with the app's own secret like a browser session, because it names a
|
||||
person in exactly the same way: ``sub`` is the account that approved the
|
||||
pairing, so everything the panel does is attributable to them. What keeps
|
||||
it from being a full session is the ``panel`` claim — the request filter in
|
||||
``app.api.deps`` lets it reach only that panel's dashboards and the message
|
||||
endpoints its widgets need.
|
||||
|
||||
Long-lived on purpose: a wall tablet is set up once and left running, and
|
||||
it has no keyboard to log in again with.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"aud": PANEL_AUDIENCE,
|
||||
"panel": panel,
|
||||
"iat": now,
|
||||
"exp": now + expires_delta,
|
||||
}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_panel_token(token: str) -> dict[str, Any]:
|
||||
"""Validate a panel credential. Raises ``InvalidTokenError`` if it does not."""
|
||||
payload: dict[str, Any] = jwt.decode(
|
||||
token,
|
||||
settings.SECRET_KEY,
|
||||
algorithms=[ALGORITHM],
|
||||
audience=PANEL_AUDIENCE,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def decode_oauth_token(token: str) -> dict[str, Any]:
|
||||
"""Validate an MCP token. Raises ``InvalidTokenError`` if it does not hold."""
|
||||
payload: dict[str, Any] = jwt.decode(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Panels: which dashboards a given device shows.
|
||||
|
||||
A wall tablet in the hall and one in the workshop want different dashboards,
|
||||
and the same dashboard may hang on both. Rather than nesting pages inside a
|
||||
dashboard, a panel names an ordered set of whole dashboards — each keeps its
|
||||
own canvas, its own draft and its own version, and the device switches between
|
||||
them through a rail.
|
||||
|
||||
Stored beside the flows rather than in them, like the alerting configuration:
|
||||
which screen hangs where is the deployment's concern, not any one dashboard's.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.core.config import settings
|
||||
from app.flow.schemas import _validate_name
|
||||
|
||||
|
||||
class PanelDef(BaseModel):
|
||||
"""One device, and what it shows."""
|
||||
|
||||
id: str
|
||||
title: str = ""
|
||||
#: Ordered. The first one is what the device opens after pairing, and the
|
||||
#: rail follows this order. A name that no longer resolves is simply a
|
||||
#: dashboard someone deleted; the panel skips it.
|
||||
dashboards: list[str] = Field(default_factory=list)
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def _check_id(cls, value: str) -> str:
|
||||
return _validate_name(value)
|
||||
|
||||
|
||||
class PanelsConfig(BaseModel):
|
||||
"""Every panel this installation knows about."""
|
||||
|
||||
panels: list[PanelDef] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
return settings.PANELS_FILE
|
||||
|
||||
|
||||
def read_config() -> PanelsConfig:
|
||||
"""The stored panels, or none. Blocking."""
|
||||
path = _path()
|
||||
if not path.exists():
|
||||
return PanelsConfig()
|
||||
try:
|
||||
return PanelsConfig.model_validate_json(path.read_text())
|
||||
except Exception:
|
||||
# A hand-edited file that no longer parses must not lock everyone out.
|
||||
return PanelsConfig()
|
||||
|
||||
|
||||
def write_config(config: PanelsConfig) -> None:
|
||||
"""Blocking."""
|
||||
path = _path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(config.model_dump_json(indent=2))
|
||||
|
||||
|
||||
def find(panel_id: str) -> PanelDef | None:
|
||||
"""The panel by that id, or None if it was removed.
|
||||
|
||||
Read from disk on every call: this is what makes deleting a panel revoke
|
||||
its credential, so it has to see the current file rather than a cache.
|
||||
"""
|
||||
for panel in read_config().panels:
|
||||
if panel.id == panel_id:
|
||||
return panel
|
||||
return None
|
||||
@@ -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
|
||||
)
|
||||
@@ -19,6 +19,7 @@ def flow_data(tmp_path_factory: pytest.TempPathFactory) -> Generator[None, None,
|
||||
root = tmp_path_factory.mktemp("flow-data")
|
||||
settings.FLOWS_DIR = root / "flows"
|
||||
settings.SECRETS_FILE = root / "secrets.enc"
|
||||
settings.PANELS_FILE = root / "panels.json"
|
||||
yield
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user