Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Failing after 36s
Playwright Tests / test-playwright (2, 2) (push) Failing after 20s
pre-commit / pre-commit (push) Failing after 2m13s
Test Backend / test-backend (3.12) (push) Successful in 4m32s
Test Backend / test-backend (3.14) (push) Successful in 4m18s
Compose Smoke Test / test-compose (push) Failing after 26s
Playwright Tests / merge-reports (push) Failing after 2m16s
1877 lines
69 KiB
Python
1877 lines
69 KiB
Python
"""Devices: assignment, pairing, and what a paired credential may reach."""
|
|
|
|
import threading
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from starlette.websockets import WebSocketDisconnect
|
|
|
|
from fluksio.api.routes import devices as devices_route
|
|
from fluksio.api.routes import oauth
|
|
from fluksio.cloud import config as cloud_config
|
|
from fluksio.core.config import settings
|
|
from fluksio.flow.devices import DeviceState
|
|
from fluksio.main import app
|
|
|
|
PREFIX = f"{settings.API_V1_STR}/devices"
|
|
#: What the openpanel firmware calls, kept as aliases.
|
|
PANELS = f"{settings.API_V1_STR}/panels"
|
|
DASHBOARDS = f"{settings.API_V1_STR}/dashboards"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _pairing_unlimited() -> None:
|
|
"""Every test pairs from the test client's one address, which the
|
|
per-address limit is about. The test of that limit spends it itself."""
|
|
for bucket in [b for b in oauth._hits if b.startswith("pair:")]:
|
|
del oauth._hits[bucket]
|
|
|
|
|
|
def _panels(client: TestClient, headers: dict[str, str], config: dict) -> None:
|
|
"""Make the stored devices exactly ``config["panels"]``, one call each."""
|
|
wanted = {device["id"]: device for device in config["panels"]}
|
|
stored = client.get(f"{PREFIX}/", headers=headers).json()["data"]
|
|
for device in stored:
|
|
if device["id"] not in wanted:
|
|
gone = client.delete(f"{PREFIX}/{device['id']}", headers=headers)
|
|
assert gone.status_code == 200, gone.text
|
|
versions = {device["id"]: device["version"] for device in stored}
|
|
for device_id, device in wanted.items():
|
|
if device_id not in versions:
|
|
created = client.post(f"{PREFIX}/{device_id}", headers=headers)
|
|
assert created.status_code == 200, created.text
|
|
versions[device_id] = created.json()["version"]
|
|
saved = client.put(
|
|
f"{PREFIX}/{device_id}",
|
|
headers=headers,
|
|
json={**device, "version": versions[device_id]},
|
|
)
|
|
assert saved.status_code == 200, saved.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["device"] == collected["panel"] == panel
|
|
return {"Authorization": f"Bearer {collected['access_token']}"}
|
|
|
|
|
|
def test_devices_require_authentication(client: TestClient) -> None:
|
|
assert client.get(f"{PREFIX}/").status_code == 401
|
|
assert client.put(f"{PREFIX}/hall", json={"id": "hall"}).status_code == 401
|
|
assert client.get(f"{PREFIX}/pending").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()
|
|
hall = next(device for device in stored["data"] if device["id"] == "hall")
|
|
assert hall["dashboards"] == ["hall_a", "hall_b"]
|
|
assert hall["online"] is False
|
|
# 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_touch_belongs_to_the_device(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""Whether a screen is touched is a fact about the screen, not the document.
|
|
|
|
The same dashboard may hang on a hallway tablet and on a desk browser, so
|
|
the flag rides on the panel and each one answers for itself.
|
|
"""
|
|
client.post(f"{DASHBOARDS}/shared", headers=superuser_token_headers)
|
|
_panels(
|
|
client,
|
|
superuser_token_headers,
|
|
{
|
|
"panels": [
|
|
{"id": "wall", "dashboards": ["shared"], "touch": True},
|
|
{"id": "desk", "dashboards": ["shared"]},
|
|
]
|
|
},
|
|
)
|
|
|
|
by_id = {
|
|
device["id"]: device
|
|
for device in client.get(f"{PREFIX}/", headers=superuser_token_headers).json()[
|
|
"data"
|
|
]
|
|
}
|
|
assert by_id["wall"]["touch"] is True
|
|
# Absent is pointed at.
|
|
assert by_id["desk"]["touch"] is False
|
|
|
|
|
|
def test_creating_twice_or_a_reserved_id_is_refused(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
assert (
|
|
client.post(f"{PREFIX}/twice", headers=superuser_token_headers).status_code
|
|
== 200
|
|
)
|
|
assert (
|
|
client.post(f"{PREFIX}/twice", headers=superuser_token_headers).status_code
|
|
== 409
|
|
)
|
|
assert (
|
|
client.post(f"{PREFIX}/pending", headers=superuser_token_headers).status_code
|
|
== 422
|
|
)
|
|
|
|
|
|
def test_concurrent_saves_of_one_version_give_one_409(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""The race `PUT /panels/` had: one of two saves of the same version wins."""
|
|
created = client.post(
|
|
f"{PREFIX}/racy", headers=superuser_token_headers, json={"title": "Racy"}
|
|
).json()
|
|
statuses: list[int] = []
|
|
|
|
def save(title: str) -> None:
|
|
statuses.append(
|
|
client.put(
|
|
f"{PREFIX}/racy",
|
|
headers=superuser_token_headers,
|
|
json={**created, "title": title},
|
|
).status_code
|
|
)
|
|
|
|
threads = [threading.Thread(target=save, args=(t,)) for t in ("A", "B")]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join()
|
|
assert sorted(statuses) == [200, 409]
|
|
|
|
stored = client.get(f"{PREFIX}/racy", headers=superuser_token_headers).json()
|
|
assert stored["version"] == created["version"] + 1
|
|
assert stored["title"] in ("A", "B")
|
|
|
|
|
|
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
|
|
# Where the firmware from before devices reads itself.
|
|
assert client.get(f"{PANELS}/hall", headers=panel_headers).status_code == 200
|
|
# Not another device.
|
|
_panels(
|
|
client,
|
|
superuser_token_headers,
|
|
{"panels": [{"id": "hall", "dashboards": ["panel_shown"]}, {"id": "other"}]},
|
|
)
|
|
assert client.get(f"{PREFIX}/other", headers=panel_headers).status_code == 403
|
|
# But not the catalogue, which is the whole namespace at once and no
|
|
# screen's business.
|
|
assert (
|
|
client.get(
|
|
f"{settings.API_V1_STR}/messages/", headers=panel_headers
|
|
).status_code
|
|
== 403
|
|
)
|
|
|
|
# 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
|
|
|
|
|
|
#: One of each input widget, a querying chart and a plain reading — the set a
|
|
#: seeded example draws on. Between them they name every message a panel is
|
|
#: entitled to speak, and nothing names ``demo.unrelated``.
|
|
PANEL_WIDGETS = [
|
|
{"id": "w_button", "type": "button", "config": {"target": "demo.button"}},
|
|
{
|
|
"id": "w_switch",
|
|
"type": "switch",
|
|
"config": {
|
|
"target": "demo.switch",
|
|
"message": "demo.switch_state",
|
|
"dtype": "bool",
|
|
},
|
|
},
|
|
{
|
|
"id": "w_slider",
|
|
"type": "slider",
|
|
"config": {
|
|
"target": "demo.slider",
|
|
"dtype": "float",
|
|
"reading": {"message": "demo.slider_reading", "dtype": "float"},
|
|
},
|
|
},
|
|
{"id": "w_input", "type": "input", "config": {"target": "demo.input"}},
|
|
{"id": "w_dropdown", "type": "dropdown", "config": {"target": "demo.dropdown"}},
|
|
{
|
|
"id": "w_buttons",
|
|
"type": "buttons",
|
|
"config": {
|
|
"target": "demo.buttons",
|
|
"buttons": [{"label": "One", "value": 1}],
|
|
},
|
|
},
|
|
{
|
|
"id": "w_query",
|
|
"type": "chart",
|
|
"config": {
|
|
"source": "query",
|
|
"request": "demo.query_request",
|
|
"request_dtype": "record",
|
|
"message": "demo.query_series",
|
|
"dtype": "series",
|
|
},
|
|
},
|
|
{"id": "w_stat", "type": "stat", "config": {"message": "demo.temperature"}},
|
|
]
|
|
|
|
|
|
def _dashboard_with(
|
|
client: TestClient, headers: dict[str, str], name: str, widgets: list[dict]
|
|
) -> None:
|
|
"""A published dashboard carrying these widgets."""
|
|
saved = _dashboard(client, headers, name)
|
|
saved["widgets"] = widgets
|
|
written = client.put(f"{DASHBOARDS}/{name}", headers=headers, json=saved)
|
|
assert written.status_code == 200, written.text
|
|
published = client.post(
|
|
f"{DASHBOARDS}/{name}/publish",
|
|
headers=headers,
|
|
json={"version": written.json()["version"]},
|
|
)
|
|
assert published.status_code == 200, published.text
|
|
|
|
|
|
def test_a_panel_speaks_only_its_own_widgets_messages(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""The allowlist is a walk of the panel's widgets, not the whole namespace.
|
|
|
|
404 rather than 200 for the ones it may publish: the gate lets them
|
|
through and the engine then refuses them because no flow in this suite
|
|
declares them. What matters here is that it is not 403.
|
|
"""
|
|
_dashboard_with(client, superuser_token_headers, "panel_controls", PANEL_WIDGETS)
|
|
_panels(
|
|
client,
|
|
superuser_token_headers,
|
|
{"panels": [{"id": "workshop", "dashboards": ["panel_controls"]}]},
|
|
)
|
|
panel_headers = _pair(client, superuser_token_headers, "workshop")
|
|
messages = f"{settings.API_V1_STR}/messages"
|
|
|
|
# Every input widget publishes, and so does a querying chart — its request
|
|
# is a value it puts into the graph, and a wall panel with no way to send
|
|
# it would draw nothing.
|
|
for target in (
|
|
"demo.button",
|
|
"demo.switch",
|
|
"demo.slider",
|
|
"demo.input",
|
|
"demo.dropdown",
|
|
"demo.buttons",
|
|
"demo.query_request",
|
|
):
|
|
assert (
|
|
client.post(
|
|
f"{messages}/{target}", headers=panel_headers, json={"value": 1}
|
|
).status_code
|
|
== 404
|
|
), target
|
|
|
|
# And reads the history of everything its widgets bind to, but writes none
|
|
# of what they only draw.
|
|
for name in (
|
|
"demo.temperature",
|
|
"demo.switch_state",
|
|
"demo.slider_reading",
|
|
"demo.query_series",
|
|
):
|
|
assert (
|
|
client.get(f"{messages}/{name}/history", headers=panel_headers).status_code
|
|
== 200
|
|
), name
|
|
assert (
|
|
client.post(
|
|
f"{messages}/{name}", headers=panel_headers, json={"value": 1}
|
|
).status_code
|
|
== 403
|
|
), name
|
|
|
|
# A message no tile on this panel names is not its business, in either
|
|
# direction.
|
|
assert (
|
|
client.post(
|
|
f"{messages}/demo.unrelated", headers=panel_headers, json={"value": 1}
|
|
).status_code
|
|
== 403
|
|
)
|
|
assert (
|
|
client.get(
|
|
f"{messages}/demo.unrelated/history", headers=panel_headers
|
|
).status_code
|
|
== 403
|
|
)
|
|
|
|
|
|
def _publish_settings(
|
|
client: TestClient, headers: dict[str, str], name: str, settings_: dict
|
|
) -> None:
|
|
"""Give a published dashboard these settings and publish it again."""
|
|
saved = client.get(f"{DASHBOARDS}/{name}?draft=true", headers=headers).json()
|
|
saved["settings"] = settings_
|
|
written = client.put(f"{DASHBOARDS}/{name}", headers=headers, json=saved)
|
|
assert written.status_code == 200, written.text
|
|
published = client.post(
|
|
f"{DASHBOARDS}/{name}/publish",
|
|
headers=headers,
|
|
json={"version": written.json()["version"]},
|
|
)
|
|
assert published.status_code == 200, published.text
|
|
|
|
|
|
def test_a_locked_dashboard_entitles_a_panel_to_read_and_not_to_publish(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""`locked` bounds what a screen may send, not what it may draw.
|
|
|
|
It used to bound neither: the client stopped offering the control and the
|
|
server took the publish from anything that asked anyway.
|
|
|
|
Unions like the allowlist itself, which is what the second dashboard here
|
|
is for — a message a locked dashboard shows and an unlocked one controls
|
|
stays writable, because the unlocked one is what entitles the panel to it.
|
|
"""
|
|
_dashboard_with(client, superuser_token_headers, "panel_locked", PANEL_WIDGETS)
|
|
_publish_settings(
|
|
client, superuser_token_headers, "panel_locked", {"locked": {"value": True}}
|
|
)
|
|
_dashboard_with(
|
|
client,
|
|
superuser_token_headers,
|
|
"panel_open",
|
|
[{"id": "w_button", "type": "button", "config": {"target": "demo.button"}}],
|
|
)
|
|
_panels(
|
|
client,
|
|
superuser_token_headers,
|
|
{"panels": [{"id": "foyer", "dashboards": ["panel_locked", "panel_open"]}]},
|
|
)
|
|
panel_headers = _pair(client, superuser_token_headers, "foyer")
|
|
messages = f"{settings.API_V1_STR}/messages"
|
|
|
|
# Only the locked dashboard names it, so there is nothing left to send it.
|
|
assert (
|
|
client.post(
|
|
f"{messages}/demo.slider", headers=panel_headers, json={"value": 1}
|
|
).status_code
|
|
== 403
|
|
)
|
|
# 404, not 403: past the gate, and refused by an engine that knows no such
|
|
# message. The unlocked dashboard binds this one too.
|
|
assert (
|
|
client.post(
|
|
f"{messages}/demo.button", headers=panel_headers, json={"value": 1}
|
|
).status_code
|
|
== 404
|
|
)
|
|
# A querying chart's request survives the lock: publishing it is how that
|
|
# tile *reads*, and a locked dashboard whose charts cannot ask goes blank
|
|
# rather than read-only.
|
|
assert (
|
|
client.post(
|
|
f"{messages}/demo.query_request",
|
|
headers=panel_headers,
|
|
json={"value": {"range_s": 3600, "interval_s": 60}},
|
|
).status_code
|
|
== 404
|
|
)
|
|
# And what the locked dashboard draws is still readable — read-only is not
|
|
# blind, and a wall panel showing stale numbers is the failure to avoid.
|
|
assert (
|
|
client.get(
|
|
f"{messages}/demo.temperature/history", headers=panel_headers
|
|
).status_code
|
|
== 200
|
|
)
|
|
|
|
|
|
FLOWS = f"{settings.API_V1_STR}/flows"
|
|
|
|
|
|
def _device_node(device: str, node: str = "porch", **ports: list) -> dict:
|
|
"""A device node: ``provides`` its ``in`` ports, ``requires`` its ``out`` ones."""
|
|
return {
|
|
"id": node,
|
|
"type": "device",
|
|
"params": {"device": device, "config": ports.pop("config", {})},
|
|
"provides": ports.get("provides", []),
|
|
"requires": ports.get("requires", []),
|
|
}
|
|
|
|
|
|
def _publish_flow(
|
|
client: TestClient, headers: dict[str, str], name: str, nodes: list, **more
|
|
) -> dict:
|
|
"""Write a flow and publish it, replacing whatever was there."""
|
|
client.delete(f"{FLOWS}/{name}", headers=headers)
|
|
saved = client.put(
|
|
f"{FLOWS}/{name}",
|
|
headers=headers,
|
|
json={"name": name, "nodes": nodes, "version": 0, **more},
|
|
)
|
|
assert saved.status_code == 200, saved.text
|
|
published = client.post(
|
|
f"{FLOWS}/{name}/publish",
|
|
headers=headers,
|
|
json={"version": saved.json()["definition"]["version"]},
|
|
)
|
|
assert published.status_code == 200, published.text
|
|
return published.json()
|
|
|
|
|
|
#: A doorbell button and the backlight, as a device node in flow ``porch``.
|
|
PORCH_NODE = _device_node(
|
|
"porch_io",
|
|
provides=[{"name": "doorbell", "port": "gpio.4", "dtype": "bool"}],
|
|
requires=[
|
|
{"name": "backlight", "port": "backlight", "dtype": "int", "trigger": False}
|
|
],
|
|
config={"gpio.4": {"pull": "up", "debounce_ms": 50}},
|
|
)
|
|
|
|
|
|
def test_a_devices_io_comes_from_its_device_nodes(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""What the device reads as ``io`` is what published flows wire, in port order.
|
|
|
|
The same bytes for as long as nothing changes: the firmware compares the
|
|
whole document and rebuilds its screen on any difference.
|
|
"""
|
|
_panels(client, superuser_token_headers, {"panels": [{"id": "porch_io"}]})
|
|
_publish_flow(client, superuser_token_headers, "porch", [PORCH_NODE])
|
|
# A draft wires nothing until it is published.
|
|
client.put(
|
|
f"{FLOWS}/porch_draft",
|
|
headers=superuser_token_headers,
|
|
json={
|
|
"name": "porch_draft",
|
|
"nodes": [
|
|
_device_node(
|
|
"porch_io",
|
|
provides=[{"name": "mic", "port": "mic", "dtype": "audio"}],
|
|
)
|
|
],
|
|
"version": 0,
|
|
},
|
|
)
|
|
|
|
first = client.get(f"{PREFIX}/porch_io", headers=superuser_token_headers)
|
|
read = first.json()
|
|
assert read["io"] == [
|
|
{
|
|
"port": "backlight",
|
|
"message": "porch.backlight",
|
|
"direction": "out",
|
|
"config": {},
|
|
},
|
|
{
|
|
"port": "gpio.4",
|
|
"message": "porch.doorbell",
|
|
"direction": "in",
|
|
"config": {"pull": "up", "debounce_ms": 50},
|
|
},
|
|
]
|
|
assert [(b["flow"], b["node"], b["port"]) for b in read["bindings"]] == [
|
|
("porch", "porch", "backlight"),
|
|
("porch", "porch", "gpio.4"),
|
|
]
|
|
again = client.get(f"{PANELS}/porch_io", headers=superuser_token_headers)
|
|
assert again.content == first.content
|
|
# The list carries the same, for the Devices tab.
|
|
listed = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
|
|
assert next(d for d in listed["data"] if d["id"] == "porch_io")["io"] == read["io"]
|
|
for flow in ("porch", "porch_draft"):
|
|
client.delete(f"{FLOWS}/{flow}", headers=superuser_token_headers)
|
|
|
|
|
|
def test_a_devices_own_ports_publish_through_a_lock(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""An ``in`` port is the device's own sensor, which a dashboard lock is not about.
|
|
|
|
An ``out`` port is the device following a message, which it publishes too
|
|
when set on the device itself (a panel's brightness on its config page).
|
|
Only an ``in`` port's name opens the artifact upload. A value published on
|
|
a bound ``in`` port is the device node's, whatever the device says it is
|
|
from.
|
|
"""
|
|
_dashboard_with(client, superuser_token_headers, "panel_io_locked", PANEL_WIDGETS)
|
|
_publish_settings(
|
|
client, superuser_token_headers, "panel_io_locked", {"locked": {"value": True}}
|
|
)
|
|
_panels(
|
|
client,
|
|
superuser_token_headers,
|
|
{
|
|
"panels": [
|
|
{"id": "porch_io", "title": "Porch", "dashboards": ["panel_io_locked"]}
|
|
]
|
|
},
|
|
)
|
|
_publish_flow(client, superuser_token_headers, "porch", [PORCH_NODE])
|
|
panel_headers = _pair(client, superuser_token_headers, "porch_io")
|
|
messages = f"{settings.API_V1_STR}/messages"
|
|
|
|
controller = app.state.flow_controller
|
|
sources = []
|
|
original = controller.publish_message
|
|
|
|
def recorded(name, value, source=None): # type: ignore[no-untyped-def]
|
|
sources.append(source)
|
|
return original(name, value, source)
|
|
|
|
controller.publish_message = recorded
|
|
try:
|
|
published = client.post(
|
|
f"{messages}/porch.doorbell",
|
|
headers=panel_headers,
|
|
json={"value": True, "source_kind": "panel", "source_id": "panel:x:y"},
|
|
)
|
|
finally:
|
|
del controller.publish_message
|
|
assert published.status_code == 200, published.text
|
|
assert sources[0].model_dump() == {
|
|
"kind": "node",
|
|
"id": "porch.porch",
|
|
"label": "Porch",
|
|
"detail": "gpio.4",
|
|
}
|
|
|
|
assert (
|
|
client.post(
|
|
f"{messages}/porch.backlight", headers=panel_headers, json={"value": 50}
|
|
).status_code
|
|
== 200
|
|
)
|
|
assert (
|
|
client.get(
|
|
f"{messages}/porch.backlight/history", headers=panel_headers
|
|
).status_code
|
|
== 200
|
|
)
|
|
assert (
|
|
client.post(
|
|
f"{messages}/porch.unrelated", headers=panel_headers, json={"value": 1}
|
|
).status_code
|
|
== 403
|
|
)
|
|
|
|
artifacts = f"{settings.API_V1_STR}/artifacts"
|
|
for name, status in (
|
|
("porch.doorbell", 200),
|
|
("porch.backlight", 403),
|
|
("porch.unrelated", 403),
|
|
):
|
|
uploaded = client.put(
|
|
artifacts,
|
|
headers=panel_headers,
|
|
params={"name": name, "media_type": "audio/wav", "volatile": True},
|
|
content=b"RIFF",
|
|
)
|
|
assert uploaded.status_code == status, name
|
|
|
|
# Unwired, the port is nothing the credential reaches any more.
|
|
_publish_flow(client, superuser_token_headers, "porch", [])
|
|
assert (
|
|
client.post(
|
|
f"{messages}/porch.doorbell", headers=panel_headers, json={"value": True}
|
|
).status_code
|
|
== 403
|
|
)
|
|
client.delete(f"{FLOWS}/porch", headers=superuser_token_headers)
|
|
|
|
|
|
def test_a_port_wired_twice_or_an_unknown_device_is_an_issue(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
_panels(client, superuser_token_headers, {"panels": [{"id": "porch_io"}]})
|
|
_publish_flow(client, superuser_token_headers, "porch", [PORCH_NODE])
|
|
_publish_flow(
|
|
client,
|
|
superuser_token_headers,
|
|
"porch_too",
|
|
[
|
|
_device_node(
|
|
"porch_io",
|
|
provides=[{"name": "bell", "port": "gpio.4", "dtype": "bool"}],
|
|
),
|
|
_device_node("ghost", node="ghost"),
|
|
],
|
|
)
|
|
|
|
def codes(flow: str) -> set[tuple[str, str | None, bool]]:
|
|
issues = client.get(f"{FLOWS}/{flow}", headers=superuser_token_headers).json()[
|
|
"issues"
|
|
]
|
|
return {
|
|
(i["code"], i["port"], i["advisory"])
|
|
for i in issues
|
|
if i["code"] in ("device_port", "unknown_device")
|
|
}
|
|
|
|
assert codes("porch") == {("device_port", "gpio.4", False)}
|
|
assert codes("porch_too") == {
|
|
("device_port", "gpio.4", False),
|
|
("unknown_device", None, False),
|
|
}
|
|
# The device follows the first of the two, and the tab shows both.
|
|
read = client.get(f"{PREFIX}/porch_io", headers=superuser_token_headers).json()
|
|
assert [(row["port"], row["message"]) for row in read["io"]] == [
|
|
("backlight", "porch.backlight"),
|
|
("gpio.4", "porch.doorbell"),
|
|
]
|
|
assert [b["flow"] for b in read["bindings"] if b["port"] == "gpio.4"] == [
|
|
"porch",
|
|
"porch_too",
|
|
]
|
|
# Creating the device is enough, nothing has to be published again.
|
|
_panels(
|
|
client,
|
|
superuser_token_headers,
|
|
{"panels": [{"id": "porch_io"}, {"id": "ghost"}]},
|
|
)
|
|
assert codes("porch_too") == {("device_port", "gpio.4", False)}
|
|
|
|
# With a report a port it does not list is a warning, and one it lists the
|
|
# other way round an error. Seen in a draft, which is validated as it is.
|
|
store = app.state.device_store
|
|
with store._state_lock:
|
|
states = store._read_states()
|
|
states.setdefault("ghost", DeviceState()).report = {
|
|
"schema": 1,
|
|
"ports": [{"port": "touch", "direction": "in", "dtype": "bool"}],
|
|
}
|
|
store._write_states(states)
|
|
client.put(
|
|
f"{FLOWS}/porch_too",
|
|
headers=superuser_token_headers,
|
|
json={
|
|
"name": "porch_too",
|
|
"version": client.get(
|
|
f"{FLOWS}/porch_too", headers=superuser_token_headers
|
|
).json()["definition"]["version"],
|
|
"nodes": [
|
|
_device_node(
|
|
"ghost",
|
|
node="ghost",
|
|
provides=[{"name": "relay", "port": "relay", "dtype": "bool"}],
|
|
requires=[
|
|
{
|
|
"name": "touch",
|
|
"port": "touch",
|
|
"dtype": "bool",
|
|
"trigger": False,
|
|
}
|
|
],
|
|
)
|
|
],
|
|
},
|
|
)
|
|
assert codes("porch_too") == {
|
|
("device_port", "relay", True),
|
|
("device_port", "touch", False),
|
|
}
|
|
for flow in ("porch", "porch_too"):
|
|
client.delete(f"{FLOWS}/{flow}", headers=superuser_token_headers)
|
|
|
|
|
|
#: What a P4 panel reports: a GPIO pin either way, its backlight and touch.
|
|
REPORT = {
|
|
"schema": 1,
|
|
"platform": "esp32p4",
|
|
"board": "p4",
|
|
"firmware": "0.1.2",
|
|
"display": {"w": 1024, "h": 600, "shape": "rect", "touch": True},
|
|
"ports": [
|
|
{"port": "backlight", "direction": "out", "dtype": "int"},
|
|
{"port": "touch", "direction": "in", "dtype": "bool"},
|
|
{
|
|
"port": "gpio.4",
|
|
"direction": "in",
|
|
"modes": ["in", "out"],
|
|
"dtype": "bool",
|
|
"config": {"type": "object", "properties": {"invert": {"type": "boolean"}}},
|
|
},
|
|
],
|
|
"features": {"display": True},
|
|
# A newer firmware's key, which this version drops.
|
|
"battery": {"level": 80},
|
|
}
|
|
|
|
|
|
def test_a_device_reports_what_it_has(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""Only from its own credential, at most 16 KB, the whole report replaced.
|
|
|
|
The device node's ports are checked against it from then on.
|
|
"""
|
|
_panels(
|
|
client,
|
|
superuser_token_headers,
|
|
{"panels": [{"id": "porch_io"}, {"id": "other"}]},
|
|
)
|
|
_publish_flow(client, superuser_token_headers, "porch", [PORCH_NODE])
|
|
device = _pair(client, superuser_token_headers, "porch_io")
|
|
other = _pair(client, superuser_token_headers, "other")
|
|
url = f"{PREFIX}/porch_io/report"
|
|
|
|
def issues() -> set[tuple[str | None, bool]]:
|
|
found = client.get(f"{FLOWS}/porch", headers=superuser_token_headers).json()
|
|
return {
|
|
(i["port"], i["advisory"])
|
|
for i in found["issues"]
|
|
if i["code"] == "device_port"
|
|
}
|
|
|
|
# Without a report the ports are the device's business.
|
|
assert issues() == set()
|
|
assert (
|
|
client.put(url, headers=superuser_token_headers, json=REPORT).status_code == 403
|
|
)
|
|
assert client.put(url, headers=other, json=REPORT).status_code == 403
|
|
padded = {**REPORT, "firmware": "x" * 20_000}
|
|
assert client.put(url, headers=device, json=padded).status_code == 413
|
|
assert (
|
|
client.put(url, headers=device, json={**REPORT, "schema": 2}).status_code == 422
|
|
)
|
|
|
|
put = client.put(url, headers=device, json=REPORT)
|
|
assert put.status_code == 200, put.text
|
|
listed = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
|
|
stored = next(d for d in listed["data"] if d["id"] == "porch_io")
|
|
assert stored["reported_at"]
|
|
assert "battery" not in stored["report"]
|
|
assert stored["report"]["ports"][2]["modes"] == ["in", "out"]
|
|
# A touch screen's first report makes the device touch friendly.
|
|
assert stored["touch"] is True
|
|
# What the device reads stays what it was: no report, no presence.
|
|
read = client.get(f"{PREFIX}/porch_io", headers=device).json()
|
|
assert "report" not in read and read["touch"] is True
|
|
# gpio.4 is wired as in, which its modes allow, and the backlight as int.
|
|
assert issues() == set()
|
|
|
|
# A report replaces the one before: the backlight gone, gpio.4 out only.
|
|
gpio = {"port": "gpio.4", "direction": "out", "dtype": "bool"}
|
|
assert (
|
|
client.put(url, headers=device, json={**REPORT, "ports": [gpio]}).status_code
|
|
== 200
|
|
)
|
|
assert issues() == {("gpio.4", False), ("backlight", True)}
|
|
client.delete(f"{FLOWS}/porch", headers=superuser_token_headers)
|
|
# The other tests wire porch_io without a report.
|
|
store = app.state.device_store
|
|
with store._state_lock:
|
|
states = store._read_states()
|
|
states["porch_io"].report = None
|
|
store._write_states(states)
|
|
|
|
|
|
def test_publishing_a_flow_with_device_nodes_tells_the_devices(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""An empty dashboard name is what a device refetches its document on."""
|
|
_panels(client, superuser_token_headers, {"panels": [{"id": "porch_io"}]})
|
|
controller = app.state.flow_controller
|
|
sent = []
|
|
original = controller._publish
|
|
|
|
def recorded(event): # type: ignore[no-untyped-def]
|
|
sent.append(event)
|
|
original(event)
|
|
|
|
controller._publish = recorded
|
|
try:
|
|
_publish_flow(client, superuser_token_headers, "porch", [PORCH_NODE])
|
|
told = [e for e in sent if e["type"] == "dashboard_changed"]
|
|
assert told and told[-1]["dashboard"] == ""
|
|
sent.clear()
|
|
# A flow with no device in it moves nothing a device reads.
|
|
_publish_flow(client, superuser_token_headers, "porch_plain", [])
|
|
assert not [e for e in sent if e["type"] == "dashboard_changed"]
|
|
client.delete(f"{FLOWS}/porch", headers=superuser_token_headers)
|
|
assert [e for e in sent if e["type"] == "dashboard_changed"]
|
|
finally:
|
|
del controller._publish
|
|
client.delete(f"{FLOWS}/porch_plain", headers=superuser_token_headers)
|
|
|
|
|
|
def test_a_panel_may_read_its_dashboards_own_settings(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""A bound setting is no widget's binding, and a panel still needs it.
|
|
|
|
The allowlist is a walk of what the dashboards name, so the walk has to
|
|
reach the dashboard itself: a theme driven over a message is the one thing
|
|
a screen on a wall cannot be told any other way, and a panel refused that
|
|
message would leave the feature silently doing nothing on the only surface
|
|
it exists for.
|
|
"""
|
|
from fluksio.api.routes.flows import panel_scope
|
|
|
|
_dashboard_with(
|
|
client, superuser_token_headers, "panel_themed", [PANEL_WIDGETS[-1]]
|
|
)
|
|
saved = client.get(
|
|
f"{DASHBOARDS}/panel_themed?draft=true", headers=superuser_token_headers
|
|
).json()
|
|
saved["settings"] = {
|
|
"theme": {"value": "dark", "message": "demo.panel_theme", "dtype": "str"},
|
|
# Bound to nothing: a static setting entitles a panel to nothing.
|
|
"locked": {"value": False},
|
|
}
|
|
written = client.put(
|
|
f"{DASHBOARDS}/panel_themed", headers=superuser_token_headers, json=saved
|
|
)
|
|
assert written.status_code == 200, written.text
|
|
published = client.post(
|
|
f"{DASHBOARDS}/panel_themed/publish",
|
|
headers=superuser_token_headers,
|
|
json={"version": written.json()["version"]},
|
|
)
|
|
assert published.status_code == 200, published.text
|
|
|
|
_panels(
|
|
client,
|
|
superuser_token_headers,
|
|
{"panels": [{"id": "hall", "dashboards": ["panel_themed"]}]},
|
|
)
|
|
panel_headers = _pair(client, superuser_token_headers, "hall")
|
|
messages = f"{settings.API_V1_STR}/messages"
|
|
|
|
# Not 403: the gate lets it through, and the engine then answers for a
|
|
# message no flow in this suite declares.
|
|
assert (
|
|
client.get(
|
|
f"{messages}/demo.panel_theme/history", headers=panel_headers
|
|
).status_code
|
|
== 200
|
|
)
|
|
# And the socket is bounded by the same walk, so the value actually lands.
|
|
token = panel_headers["Authorization"][7:]
|
|
assert panel_scope(token, client.app) == {
|
|
"demo.panel_theme",
|
|
"demo.temperature",
|
|
}
|
|
|
|
# A setting still buys nothing beyond itself.
|
|
assert (
|
|
client.get(
|
|
f"{messages}/demo.unrelated/history", headers=panel_headers
|
|
).status_code
|
|
== 403
|
|
)
|
|
|
|
|
|
def test_a_dashboard_setting_bound_to_the_wrong_type_is_refused(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""The same answer the server gives a mis-wired widget."""
|
|
saved = _dashboard(client, superuser_token_headers, "panel_mistyped")
|
|
saved["settings"] = {"theme": {"value": "dark", "message": "a.b", "dtype": "float"}}
|
|
|
|
refused = client.put(
|
|
f"{DASHBOARDS}/panel_mistyped", headers=superuser_token_headers, json=saved
|
|
)
|
|
assert refused.status_code == 422, refused.text
|
|
|
|
|
|
def test_unpairing_a_screen_leaves_its_panel_standing(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""Bumping the nonce revokes one credential and nothing else."""
|
|
_dashboard(client, superuser_token_headers, "panel_kept")
|
|
assigned = {
|
|
"panels": [{"id": "kitchen", "title": "Kitchen", "dashboards": ["panel_kept"]}]
|
|
}
|
|
_panels(client, superuser_token_headers, assigned)
|
|
panel_headers = _pair(client, superuser_token_headers, "kitchen")
|
|
assert (
|
|
client.get(f"{DASHBOARDS}/panel_kept", headers=panel_headers).status_code == 200
|
|
)
|
|
|
|
bumped = client.post(f"{PREFIX}/kitchen/unpair", headers=superuser_token_headers)
|
|
assert bumped.status_code == 200, bumped.text
|
|
|
|
# 401, not 403: this is no longer a credential, so the device goes back to
|
|
# showing a code rather than retrying what it was refused.
|
|
assert (
|
|
client.get(f"{DASHBOARDS}/panel_kept", headers=panel_headers).status_code == 401
|
|
)
|
|
|
|
# The panel, its title and its assignment are exactly where they were, and
|
|
# the next screen pairs to it normally.
|
|
panel = client.get(f"{PREFIX}/kitchen", headers=superuser_token_headers).json()
|
|
assert panel["title"] == "Kitchen"
|
|
assert panel["dashboards"] == ["panel_kept"]
|
|
fresh = _pair(client, superuser_token_headers, "kitchen")
|
|
assert fresh != panel_headers
|
|
assert client.get(f"{DASHBOARDS}/panel_kept", headers=fresh).status_code == 200
|
|
|
|
# A save of the device cannot undo the revocation: the nonce is not part
|
|
# of what is saved.
|
|
_panels(client, superuser_token_headers, assigned)
|
|
assert (
|
|
client.get(f"{DASHBOARDS}/panel_kept", headers=panel_headers).status_code == 401
|
|
)
|
|
assert client.get(f"{DASHBOARDS}/panel_kept", headers=fresh).status_code == 200
|
|
|
|
assert (
|
|
client.post(
|
|
f"{PREFIX}/nowhere/unpair", headers=superuser_token_headers
|
|
).status_code
|
|
== 404
|
|
)
|
|
assert client.post(f"{PREFIX}/kitchen/unpair").status_code == 401
|
|
|
|
|
|
def test_a_device_is_refused_only_when_its_credential_is_gone(
|
|
client: TestClient,
|
|
superuser_token_headers: dict[str, str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
""" "Cannot say right now" is no refusal: a refused device pairs again."""
|
|
_panels(client, superuser_token_headers, {"panels": [{"id": "hallway"}]})
|
|
device = _pair(client, superuser_token_headers, "hallway")
|
|
socket = f"{settings.API_V1_STR}/flows/ws"
|
|
store = app.state.device_store
|
|
|
|
def unreadable(_: str) -> int:
|
|
raise ValueError("torn")
|
|
|
|
# The nonces do not read: the socket is accepted and told to try again
|
|
# later, and the device's own read answers 503.
|
|
monkeypatch.setattr(store, "nonce", unreadable)
|
|
with (
|
|
client.websocket_connect(socket, headers=device) as ws,
|
|
pytest.raises(WebSocketDisconnect) as later,
|
|
):
|
|
ws.receive_json()
|
|
assert later.value.code == 1013
|
|
assert client.get(f"{PREFIX}/hallway", headers=device).status_code == 503
|
|
monkeypatch.undo()
|
|
with client.websocket_connect(socket, headers=device) as ws:
|
|
assert "values" in ws.receive_json()
|
|
|
|
# Unpaired, it is refused before it is accepted, a 403 on the wire.
|
|
client.post(f"{PREFIX}/hallway/unpair", headers=superuser_token_headers)
|
|
with (
|
|
pytest.raises(WebSocketDisconnect) as refused,
|
|
client.websocket_connect(socket, headers=device),
|
|
):
|
|
pass
|
|
assert refused.value.code == 1008
|
|
assert client.get(f"{PREFIX}/hallway", headers=device).status_code == 401
|
|
|
|
|
|
def test_only_a_device_paired_here_turns_touch_on_with_its_report(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""One paired before reports for the first time with an update, its
|
|
switch set by hand: a knob left off on purpose stays off."""
|
|
_panels(client, superuser_token_headers, {"panels": [{"id": "knob_old"}]})
|
|
device = _pair(client, superuser_token_headers, "knob_old")
|
|
store = app.state.device_store
|
|
with store._state_lock:
|
|
states = store._read_states()
|
|
states["knob_old"].paired_at = None
|
|
store._write_states(states)
|
|
|
|
put = client.put(f"{PREFIX}/knob_old/report", headers=device, json=REPORT)
|
|
assert put.status_code == 200, put.text
|
|
read = client.get(f"{PREFIX}/knob_old", headers=device).json()
|
|
assert read["touch"] is False
|
|
|
|
|
|
class _FakeRedis:
|
|
"""Just enough Redis for the pending-code store: strings and one zset.
|
|
|
|
Expiry is not enforced here — each entry carries its own, which is what the
|
|
store reads — so this only has to be shared to stand in for two workers.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.strings: dict[str, str] = {}
|
|
self.zset: dict[str, float] = {}
|
|
|
|
def set(
|
|
self, key: str, value: str, ex: int | None = None, nx: bool = False
|
|
) -> bool | None:
|
|
if nx and key in self.strings:
|
|
return None
|
|
self.strings[key] = value
|
|
return True
|
|
|
|
def get(self, key: str) -> str | None:
|
|
return self.strings.get(key)
|
|
|
|
def delete(self, key: str) -> None:
|
|
self.strings.pop(key, None)
|
|
|
|
def zadd(self, key: str, mapping: dict[str, float]) -> None:
|
|
self.zset.update(mapping)
|
|
|
|
def zcard(self, key: str) -> int:
|
|
return len(self.zset)
|
|
|
|
def zrange(self, key: str, start: int, end: int) -> list[str]:
|
|
return sorted(self.zset, key=self.zset.__getitem__)
|
|
|
|
def zrem(self, key: str, member: str) -> None:
|
|
self.zset.pop(member, None)
|
|
|
|
def zremrangebyscore(self, key: str, low: str, high: float) -> None:
|
|
for member in [m for m, score in self.zset.items() if score <= high]:
|
|
del self.zset[member]
|
|
|
|
|
|
def test_a_pending_code_is_found_by_whichever_worker_is_polled() -> None:
|
|
"""A poll lands wherever the proxy sent it, not on the minting worker."""
|
|
import time
|
|
|
|
shared = _FakeRedis()
|
|
minted = devices_route._PendingStore()
|
|
minted._redis = shared # type: ignore[assignment]
|
|
polled = devices_route._PendingStore()
|
|
polled._redis = shared # type: ignore[assignment]
|
|
|
|
entry = devices_route._Pending(
|
|
secret="s", expires=time.time() + devices_route.PAIR_TTL, device="a tablet"
|
|
)
|
|
assert minted.add("ABC123", entry)
|
|
# The cap and the collision check mean the same thing on both.
|
|
assert not polled.add("ABC123", entry)
|
|
assert polled.count() == minted.count() == 1
|
|
|
|
seen = polled.get("ABC123")
|
|
assert seen is not None and seen.device == "a tablet"
|
|
|
|
# An approval on one worker is collected from the other.
|
|
seen.token = "minted-over-there"
|
|
polled.save("ABC123", seen)
|
|
collected = minted.get("ABC123")
|
|
assert collected is not None and collected.token == "minted-over-there"
|
|
|
|
minted.drop("ABC123")
|
|
assert polled.get("ABC123") is None
|
|
assert polled.count() == 0
|
|
|
|
# An entry past its own expiry is nothing, whatever the key still says.
|
|
stale = devices_route._Pending(secret="s", expires=time.time() - 1)
|
|
assert minted.add("STALE1", stale)
|
|
assert polled.get("STALE1") is None
|
|
assert polled.count() == 0
|
|
|
|
# Making room drops the oldest, whichever worker minted it.
|
|
for offset, code in enumerate(("OLD111", "MID222", "NEW333")):
|
|
later = devices_route._Pending(secret="s", expires=time.time() + 60 + offset)
|
|
assert minted.add(code, later)
|
|
polled.make_room(3, remote=False)
|
|
assert minted.get("OLD111") is None and polled.count() == 2
|
|
|
|
|
|
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
|
|
)
|
|
|
|
# Nor does a device created again under the same id take it back.
|
|
_panels(
|
|
client,
|
|
superuser_token_headers,
|
|
{"panels": [{"id": "workshop", "dashboards": ["panel_gone"]}]},
|
|
)
|
|
assert (
|
|
client.get(f"{DASHBOARDS}/panel_gone", headers=panel_headers).status_code == 401
|
|
)
|
|
|
|
|
|
def test_firmware_from_before_devices_pairs_through_the_panel_aliases(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""The openpanel firmware's exact calls: an empty JSON body, then a poll."""
|
|
_panels(client, superuser_token_headers, {"panels": [{"id": "porch"}]})
|
|
started = client.post(
|
|
f"{PANELS}/pair", content=b"", headers={"Content-Type": "application/json"}
|
|
)
|
|
assert started.status_code == 200, started.text
|
|
code, secret = started.json()["code"], started.json()["secret"]
|
|
approved = client.post(
|
|
f"{PREFIX}/porch/pair", headers=superuser_token_headers, json={"code": code}
|
|
)
|
|
assert approved.status_code == 200, approved.text
|
|
collected = client.get(f"{PANELS}/pair/{code}", params={"secret": secret}).json()
|
|
assert collected["panel"] == "porch"
|
|
headers = {"Authorization": f"Bearer {collected['access_token']}"}
|
|
assert client.get(f"{PANELS}/porch", headers=headers).json()["id"] == "porch"
|
|
|
|
|
|
def test_a_device_on_this_network_is_adopted_from_the_pending_list(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""No code to type for a request from the LAN, but one through the portal."""
|
|
_panels(client, superuser_token_headers, {"panels": [{"id": "knob"}]})
|
|
lan = TestClient(app, client=("192.168.1.50", 50000))
|
|
started = lan.post(
|
|
f"{PREFIX}/pair", json={"name": "knob-3f2a", "board": "knob", "firmware": "1.4"}
|
|
).json()
|
|
remote = client.post(
|
|
f"{PREFIX}/pair", headers=_via_portal(), json={"name": "x" * 500}
|
|
).json()
|
|
|
|
pending = client.get(f"{PREFIX}/pending", headers=superuser_token_headers).json()
|
|
mine = next(entry for entry in pending if entry["name"] == "knob-3f2a")
|
|
assert mine["board"] == "knob" and mine["firmware"] == "1.4"
|
|
assert mine["needs_code"] is False
|
|
# The check the device shows, to compare before adopting it from here.
|
|
assert len(started["check"]) == devices_route.CHECK_LENGTH
|
|
assert set(started["check"]) <= set(devices_route.CODE_ALPHABET)
|
|
assert mine["check"] == started["check"]
|
|
portal = next(entry for entry in pending if entry["remote"])
|
|
assert portal["needs_code"] is True
|
|
assert len(portal["name"]) == devices_route.HELLO_LENGTH
|
|
# The list never hands a code out.
|
|
assert started["code"] not in str(pending) and remote["code"] not in str(pending)
|
|
|
|
refused = client.post(
|
|
f"{PREFIX}/knob/pair",
|
|
headers=superuser_token_headers,
|
|
json={"request": portal["request"]},
|
|
)
|
|
assert refused.status_code == 403
|
|
|
|
adopted = client.post(
|
|
f"{PREFIX}/knob/pair",
|
|
headers=superuser_token_headers,
|
|
json={"request": mine["request"]},
|
|
)
|
|
assert adopted.status_code == 200, adopted.text
|
|
collected = lan.get(
|
|
f"{PREFIX}/pair/{started['code']}", params={"secret": started["secret"]}
|
|
).json()
|
|
assert collected["device"] == "knob"
|
|
headers = {"Authorization": f"Bearer {collected['access_token']}"}
|
|
assert client.get(f"{PREFIX}/knob", headers=headers).status_code == 200
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# A screen that reached the portal but not this instance
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
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."""
|
|
# Behind a trusted proxy (127.0.0.1 by default), as the tunnel replays it.
|
|
proxied = TestClient(app, client=("127.0.0.1", 50000))
|
|
started = proxied.post(
|
|
f"{PREFIX}/pair",
|
|
headers={
|
|
"user-agent": "Mozilla/5.0 (X11; CrOS aarch64)",
|
|
"x-forwarded-for": "198.51.100.1, 203.0.113.7",
|
|
},
|
|
).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 hop the proxy added, not the one the device wrote in front of it.
|
|
assert "203.0.113.7" in looked.json()["device"]
|
|
assert "198.51.100.1" not 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 instance signed: the portal verifies
|
|
what crosses it, and it verifies against its own key.
|
|
"""
|
|
import httpx
|
|
|
|
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(devices_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 instance-token" # type: ignore[index]
|
|
# With the device's nonce, which the hub puts in the credential.
|
|
nonce = app.state.device_store.nonce("hallway")
|
|
assert calls[0]["json"] == {"panel": "hallway", "pnc": nonce} # 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 fluksio.api.routes.flows import (
|
|
event_for_panel,
|
|
panel_scope,
|
|
snapshot_payload,
|
|
)
|
|
|
|
saved = _dashboard(client, superuser_token_headers, "panel_socket")
|
|
saved["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
|
|
|
|
# It opens the socket with a ticket like any browser, and the ticket
|
|
# carries the panel's bound rather than widening it.
|
|
ticket = client.post(
|
|
f"{settings.API_V1_STR}/flows/ws-ticket",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert ticket.status_code == 200, ticket.text
|
|
with client.websocket_connect(
|
|
f"{settings.API_V1_STR}/flows/ws?ticket={ticket.json()['ticket']}"
|
|
) as ws:
|
|
assert ws.receive_json()["nodes"] == []
|
|
|
|
|
|
def test_a_panel_fetches_only_the_media_its_tiles_are_showing(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""A media tile needs the bytes, and only the ones its message points at."""
|
|
_dashboard_with(
|
|
client,
|
|
superuser_token_headers,
|
|
"panel_camera",
|
|
[
|
|
{
|
|
"id": "w_media",
|
|
"type": "media",
|
|
"config": {"message": "demo.frame", "dtype": "image"},
|
|
}
|
|
],
|
|
)
|
|
_panels(
|
|
client,
|
|
superuser_token_headers,
|
|
{"panels": [{"id": "porch", "dashboards": ["panel_camera"]}]},
|
|
)
|
|
panel_headers = _pair(client, superuser_token_headers, "porch")
|
|
|
|
store = client.app.state.artifact_store
|
|
shown = store.put([b"the frame on the wall"], media_type="image/png")
|
|
elsewhere = store.put([b"an artifact of some other run"])
|
|
client.app.state.flow_controller.state.update({"demo.frame": shown})
|
|
|
|
artifacts = f"{settings.API_V1_STR}/artifacts"
|
|
assert (
|
|
client.get(f"{artifacts}/{shown['digest']}", headers=panel_headers).status_code
|
|
== 200
|
|
)
|
|
# Knowing a digest is not being entitled to it: a screen reads what it draws.
|
|
assert (
|
|
client.get(
|
|
f"{artifacts}/{elsewhere['digest']}", headers=panel_headers
|
|
).status_code
|
|
== 403
|
|
)
|
|
|
|
|
|
def test_a_socket_pushes_only_the_frames_it_was_asked_for(tmp_path) -> None:
|
|
"""Bytes are the one thing this socket cannot send speculatively.
|
|
|
|
A frame a second per subscriber is affordable; every frame to every open
|
|
editor tab is not. So nothing goes until a client names what it is drawing,
|
|
and a panel credential can only name what it was already allowed to see.
|
|
"""
|
|
import orjson
|
|
|
|
from fluksio.api.routes.flows import media_frames, wanted_names
|
|
from fluksio.flow.artifacts import ArtifactStore, VolatileStore
|
|
|
|
store = ArtifactStore(tmp_path / "artifacts")
|
|
store.volatile = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000)
|
|
frame = store.put(
|
|
[b"\x89PNG..."], name="f.png", media_type="image/png", volatile=True
|
|
)
|
|
kept = store.put([b"checkpoint"], name="w.pt")
|
|
|
|
asking = orjson.dumps({"type": "media", "names": ["cam.frame", "other.frame"]})
|
|
# A person's socket gets what it asked for; a panel's is intersected with
|
|
# what it draws, so naming a message is not a way around the scope.
|
|
assert wanted_names(asking.decode(), None) == {"cam.frame", "other.frame"}
|
|
assert wanted_names(asking.decode(), {"cam.frame"}) == {"cam.frame"}
|
|
# Anything else on this socket leaves the set alone.
|
|
assert wanted_names('{"type":"ping"}', None) is None
|
|
assert wanted_names("not json", None) is None
|
|
|
|
events = [
|
|
{"type": "message_value", "name": "cam.frame", "value": frame, "ts": 1.0},
|
|
{"type": "message_value", "name": "other.frame", "value": frame, "ts": 1.0},
|
|
{"type": "message_value", "name": "cam.model", "value": kept, "ts": 1.0},
|
|
{"type": "node_log", "name": "cam.frame", "value": frame},
|
|
]
|
|
frames = media_frames(events, {"cam.frame", "cam.model"}, store)
|
|
|
|
# One frame: the ring's, for the name that asked. The durable checkpoint is
|
|
# what a fetch is for, and a log is not a value.
|
|
assert len(frames) == 1
|
|
length = int.from_bytes(frames[0][:4], "big")
|
|
header = orjson.loads(frames[0][4 : 4 + length])
|
|
assert header == {
|
|
"type": "media",
|
|
"name": "cam.frame",
|
|
"digest": frame["digest"],
|
|
"media_type": "image/png",
|
|
"ts": 1.0,
|
|
}
|
|
assert frames[0][4 + length :] == b"\x89PNG..."
|
|
|
|
# Nothing asked for, nothing sent.
|
|
assert media_frames(events, set(), store) == []
|
|
|
|
|
|
def test_only_the_newest_frame_of_a_batch_is_pushed(tmp_path) -> None:
|
|
"""A client that fell behind is not handed frames it would only draw over."""
|
|
import orjson
|
|
|
|
from fluksio.api.routes.flows import media_frames
|
|
from fluksio.flow.artifacts import ArtifactStore, VolatileStore
|
|
|
|
store = ArtifactStore(tmp_path / "artifacts")
|
|
store.volatile = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000)
|
|
first = store.put([b"one"], media_type="image/png", volatile=True)
|
|
second = store.put([b"two"], media_type="image/png", volatile=True)
|
|
|
|
frames = media_frames(
|
|
[
|
|
{"type": "message_value", "name": "cam.frame", "value": first, "ts": 1.0},
|
|
{"type": "message_value", "name": "cam.frame", "value": second, "ts": 2.0},
|
|
],
|
|
{"cam.frame"},
|
|
store,
|
|
)
|
|
|
|
assert len(frames) == 1
|
|
length = int.from_bytes(frames[0][:4], "big")
|
|
assert orjson.loads(frames[0][4 : 4 + length])["digest"] == second["digest"]
|
|
assert frames[0][4 + length :] == b"two"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# C0: limits on asking, a lenient report, and what a credential stands on
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_one_address_asks_for_a_few_codes_a_minute(client: TestClient) -> None:
|
|
lan = TestClient(app, client=("192.168.1.77", 50000))
|
|
for _ in range(devices_route.PAIR_PER_ADDRESS):
|
|
assert lan.post(f"{PREFIX}/pair").status_code == 200
|
|
refused = lan.post(f"{PANELS}/pair")
|
|
assert refused.status_code == 429
|
|
assert refused.headers["retry-after"]
|
|
# Another address is not held to this one's.
|
|
assert client.post(f"{PREFIX}/pair").status_code == 200
|
|
# Through the portal every call comes off the tunnel, so the hub limits
|
|
# those by the address it sees, and here they are not one shared bucket.
|
|
for _ in range(2 * devices_route.PAIR_PER_ADDRESS):
|
|
assert client.post(f"{PREFIX}/pair", headers=_via_portal()).status_code == 200
|
|
|
|
|
|
def test_at_the_cap_the_oldest_request_makes_room(
|
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
store = devices_route._PendingStore()
|
|
store._redis = None
|
|
monkeypatch.setattr(devices_route, "_pending", store)
|
|
monkeypatch.setattr(devices_route, "PAIR_PER_ADDRESS", 1000)
|
|
|
|
def waiting(started: dict) -> bool:
|
|
polled = client.get(
|
|
f"{PREFIX}/pair/{started['code']}", params={"secret": started["secret"]}
|
|
)
|
|
return polled.status_code == 200
|
|
|
|
here = [
|
|
client.post(f"{PREFIX}/pair").json()
|
|
for _ in range(devices_route.MAX_PENDING + 1)
|
|
]
|
|
assert store.count() == devices_route.MAX_PENDING
|
|
assert not waiting(here[0]) and waiting(here[1]) and waiting(here[-1])
|
|
|
|
# A flood through the portal pushes out its own oldest, never these.
|
|
portal = [
|
|
client.post(f"{PREFIX}/pair", headers=_via_portal()).json()
|
|
for _ in range(devices_route.MAX_PENDING + 1)
|
|
]
|
|
assert store.count() == 2 * devices_route.MAX_PENDING
|
|
assert not waiting(portal[0]) and waiting(portal[-1])
|
|
assert all(waiting(started) for started in here[1:])
|
|
|
|
|
|
def test_a_report_is_read_port_by_port(
|
|
client: TestClient,
|
|
superuser_token_headers: dict[str, str],
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""What this version cannot read is left out and logged, not refused.
|
|
|
|
A shape or direction it does not know reads as unknown, and a named config
|
|
schema is put in place.
|
|
"""
|
|
_panels(client, superuser_token_headers, {"panels": [{"id": "lenient"}]})
|
|
device = _pair(client, superuser_token_headers, "lenient")
|
|
pin = {"type": "object", "properties": {"invert": {"type": "boolean"}}}
|
|
report = {
|
|
**REPORT,
|
|
"display": {"w": 466, "h": 466, "shape": "hexagon", "touch": False},
|
|
"config_schemas": {"pin": pin},
|
|
"ports": [
|
|
{"port": "gpio.4", "direction": "in", "dtype": "bool", "config": "pin"},
|
|
{"port": "gpio.5", "direction": "both", "dtype": "bool", "config": "pin"},
|
|
{"port": "Not A Port", "direction": "in", "dtype": "bool"},
|
|
{"port": "lidar", "direction": "in", "dtype": "pointcloud"},
|
|
],
|
|
"features": {"display": True, "camera": "imx415"},
|
|
}
|
|
with caplog.at_level("WARNING", logger="fluksio.api.routes.devices"):
|
|
put = client.put(f"{PREFIX}/lenient/report", headers=device, json=report)
|
|
assert put.status_code == 200, put.text
|
|
assert "2 ports reported, 3 left out" in put.json()["message"]
|
|
logged = " ".join(r.getMessage() for r in caplog.records)
|
|
for part in ("'Not A Port'", "'lidar'", "feature 'camera'"):
|
|
assert part in logged
|
|
|
|
listed = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
|
|
stored = next(d for d in listed["data"] if d["id"] == "lenient")["report"]
|
|
assert stored["display"]["shape"] == "unknown"
|
|
assert stored["ports"] == [
|
|
{"port": "gpio.4", "direction": "in", "dtype": "bool", "config": pin},
|
|
{"port": "gpio.5", "direction": "unknown", "dtype": "bool", "config": pin},
|
|
]
|
|
assert stored["features"] == {"display": True}
|
|
assert "config_schemas" not in stored
|
|
|
|
# Only no structure at all is refused.
|
|
for body in ([], "report", {"ports": []}):
|
|
refused = client.put(f"{PREFIX}/lenient/report", headers=device, json=body)
|
|
assert refused.status_code == 422, body
|
|
|
|
# Deleting the device drops the report and keeps the nonce moving.
|
|
store = app.state.device_store
|
|
nonce = store.nonce("lenient")
|
|
client.delete(f"{PREFIX}/lenient", headers=superuser_token_headers)
|
|
assert store.state("lenient").report is None
|
|
assert store.nonce("lenient") == nonce + 1
|
|
|
|
|
|
def test_a_busy_engine_rechecks_once_it_is_free(
|
|
client: TestClient,
|
|
superuser_token_headers: dict[str, str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from fluksio.flow.controller import RebuildBusy
|
|
|
|
_panels(client, superuser_token_headers, {"panels": [{"id": "busy"}]})
|
|
device = _pair(client, superuser_token_headers, "busy")
|
|
controller = app.state.flow_controller
|
|
waits: list[float | None] = []
|
|
|
|
async def recheck(wait: float | None = 5.0) -> None:
|
|
waits.append(wait)
|
|
if wait is not None:
|
|
raise RebuildBusy("busy")
|
|
|
|
monkeypatch.setattr(controller, "recheck", recheck)
|
|
put = client.put(f"{PREFIX}/busy/report", headers=device, json=REPORT)
|
|
assert put.status_code == 200, put.text
|
|
# The background recheck waits for the lock rather than giving up.
|
|
client.get(f"{PREFIX}/busy", headers=device)
|
|
assert waits == [5.0, None]
|
|
|
|
|
|
def test_a_device_outlives_the_account_that_paired_it(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""It acts as itself, not as whoever approved it."""
|
|
from tests.utils.user import user_authentication_headers
|
|
from tests.utils.utils import random_email, random_lower_string
|
|
|
|
email, password = random_email(), random_lower_string()
|
|
created = client.post(
|
|
f"{settings.API_V1_STR}/users/",
|
|
headers=superuser_token_headers,
|
|
json={"email": email, "password": password, "is_superuser": True},
|
|
)
|
|
assert created.status_code == 200, created.text
|
|
approver = user_authentication_headers(
|
|
client=client, email=email, password=password
|
|
)
|
|
_dashboard(client, superuser_token_headers, "outlived")
|
|
_panels(
|
|
client,
|
|
superuser_token_headers,
|
|
{"panels": [{"id": "orphan", "dashboards": ["outlived"]}]},
|
|
)
|
|
device = _pair(client, approver, "orphan")
|
|
|
|
gone = client.delete(
|
|
f"{settings.API_V1_STR}/users/{created.json()['id']}",
|
|
headers=superuser_token_headers,
|
|
)
|
|
assert gone.status_code == 200, gone.text
|
|
assert client.get(f"{PREFIX}/orphan", headers=device).status_code == 200
|
|
assert client.get(f"{DASHBOARDS}/outlived", headers=device).status_code == 200
|
|
# Still no more than a device: nothing a superuser's route serves.
|
|
assert client.get(f"{PREFIX}/", headers=device).status_code == 403
|
|
client.delete(f"{PREFIX}/orphan", headers=superuser_token_headers)
|
|
|
|
|
|
def test_a_stopped_flow_wires_no_ports(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
_panels(client, superuser_token_headers, {"panels": [{"id": "porch_io"}]})
|
|
_publish_flow(client, superuser_token_headers, "porch", [PORCH_NODE])
|
|
device = _pair(client, superuser_token_headers, "porch_io")
|
|
messages = f"{settings.API_V1_STR}/messages"
|
|
|
|
def io() -> list[str]:
|
|
read = client.get(f"{PREFIX}/porch_io", headers=device).json()
|
|
return [row["port"] for row in read["io"]]
|
|
|
|
assert io() == ["backlight", "gpio.4"]
|
|
client.post(f"{FLOWS}/porch/stop", headers=superuser_token_headers)
|
|
assert io() == []
|
|
assert (
|
|
client.post(
|
|
f"{messages}/porch.doorbell", headers=device, json={"value": True}
|
|
).status_code
|
|
== 403
|
|
)
|
|
client.post(f"{FLOWS}/porch/start", headers=superuser_token_headers)
|
|
assert io() == ["backlight", "gpio.4"]
|
|
client.delete(f"{FLOWS}/porch", headers=superuser_token_headers)
|