Files
app/backend/tests/flow/test_device_store.py
T
Melvin Strobl c604cfdf3b Device security and robustness (openpanel C0): check code, pnc, lenient report
- POST /devices/pair answers a 2-character `check`, listed by /devices/pending
  and shown beside each waiting entry; five codes a minute per address, and at
  50 waiting the oldest gives way instead of a 429
- portal-minted device tokens are asked for with the device's nonce (`pnc`) and
  held against it, so unpair and delete revoke them; one without `pnc` stands
  only while the nonce is 0
- a device credential acts as the device (`device_principal`), not as the
  approving superuser, so deleting that account unpairs nothing
- the report is validated port by port: unknown enum values read as `unknown`,
  unreadable ports and sections are left out and logged, `config` may name an
  entry of `config_schemas`; 422 only for no schema 1 or no object
- devices.json is parsed once per change (cached by stat), delete drops the
  report and keeps the nonce, a busy recheck runs in the background
- scope and io only from enabled flows (start/stop tells the devices), a port
  bound twice gives one io row
- the Devices tab's saves cancel and hold back device refetches until settled
2026-10-16 19:26:05 +02:00

257 lines
9.2 KiB
Python

"""The one-time move of an older instance's panels file into devices."""
import json
import subprocess
from pathlib import Path
from fluksio.flow.devices import DeviceStore
from fluksio.flow.store import FlowStore
def test_panels_move_into_devices_once(tmp_path: Path) -> None:
flows = FlowStore(tmp_path / "flows")
panels = tmp_path / "panels.json"
panels.write_text(
json.dumps(
{
"panels": [
{"id": "hall", "title": "Hall", "dashboards": ["home"], "nonce": 2},
{"id": "knob", "touch": True, "io": [], "nonce": 0},
],
"version": 7,
}
)
)
store = DeviceStore(flows, tmp_path / "devices.json")
store.migrate(panels)
assert not panels.exists()
assert (tmp_path / "panels.json.migrated").exists()
hall = store.find("hall")
assert hall is not None and hall.dashboards == ["home"] and hall.version == 1
assert store.find("knob").touch is True # type: ignore[union-attr]
# The nonce stays outside git, so a credential minted at it still stands.
assert store.nonce("hall") == 2
assert "nonce" not in (flows.root / "_devices" / "hall.json").read_text()
log = subprocess.run(
["git", "-C", str(flows.root), "log", "--format=%s"],
capture_output=True,
text=True,
).stdout
assert log.splitlines()[0] == "Migrate panels to devices"
# Nothing in `_devices` is taken for a flow.
assert flows.list_flows() == []
# A second start finds nothing to do, and a panels file restored from a
# backup does not lower a nonce an unpair has raised since.
store.bump("hall")
panels.write_text((tmp_path / "panels.json.migrated").read_text())
store.migrate(panels)
assert store.nonce("hall") == 3
assert (
log
== subprocess.run(
["git", "-C", str(flows.root), "log", "--format=%s"],
capture_output=True,
text=True,
).stdout
)
def _flow(flows: FlowStore, name: str, draft: dict | None = None, **published) -> None:
"""Write a published flow document, and a draft beside it if given."""
folder = flows.root / name
folder.mkdir(parents=True, exist_ok=True)
(folder / "flow.json").write_text(json.dumps({"name": name, **published}))
if draft is not None:
(folder / "flow.draft.json").write_text(json.dumps({"name": name, **draft}))
def test_device_ports_move_into_flows_once(tmp_path: Path) -> None:
flows = FlowStore(tmp_path / "flows")
_flow(
flows,
"home",
version=3,
inputs=[
{"spec": {"name": "brightness", "dtype": "int"}, "initial": 80},
],
nodes=[
{
"id": "bell",
"type": "python",
"requires": [{"name": "doorbell", "dtype": "bool"}],
}
],
draft={
"version": 5,
"inputs": [{"spec": {"name": "brightness", "dtype": "int"}, "initial": 80}],
"nodes": [
{
"id": "bell",
"type": "python",
"requires": [{"name": "doorbell", "dtype": "bool"}],
},
{"id": "hall", "type": "inject"},
],
},
)
# Declares `home.light` from outside: a row on it goes to the owner first,
# and only here when the owner does not declare it.
_flow(
flows,
"lights",
nodes=[
{
"id": "dim",
"type": "python",
"provides": [{"name": "home.light", "dtype": "float"}],
}
],
)
devices = flows.root / "_devices"
devices.mkdir()
(devices / "hall.json").write_text(
json.dumps(
{
"id": "hall",
"title": "Hall",
"version": 4,
"io": [
{
"port": "backlight",
"message": "home.brightness",
"direction": "out",
},
{
"port": "gpio.4",
"message": "home.doorbell",
"direction": "in",
"config": {"pull": "up"},
},
{"port": "dimmer", "message": "home.light", "direction": "out"},
{"port": "mic", "message": "home.nowhere", "direction": "in"},
],
}
)
)
store = DeviceStore(flows, tmp_path / "devices.json")
store.move_io()
published = flows.read_flow("home")
draft = flows.read_flow("home", draft=True)
assert (published.version, draft.version) == (4, 6)
for doc in (published, draft):
# `hall` is taken by an inject node in the draft, so the id moves on in
# both documents alike.
node = next(n for n in doc.nodes if n.type == "device")
assert node.id == "hall_2" and node.title == "Hall"
assert node.params == {"device": "hall", "config": {"gpio.4": {"pull": "up"}}}
assert [(s.port, s.name, s.dtype.value) for s in node.provides] == [
("gpio.4", "doorbell", "bool")
]
assert [(s.port, s.name, s.dtype.value, s.trigger) for s in node.requires] == [
("backlight", "brightness", "int", False)
]
lights = flows.read_flow("lights")
node = next(n for n in lights.nodes if n.type == "device")
assert node.id == "hall" and lights.version == 2
assert [(s.port, s.name) for s in node.requires] == [("dimmer", "home.light")]
# The device lost `io` and kept its version; the row nothing declares is
# gone. What it reads back is the same rows, in port order.
assert "io" not in json.loads((devices / "hall.json").read_text())
assert store.find("hall").version == 4 # type: ignore[union-attr]
assert [(r.port, r.message, r.direction, r.config) for r in store.io("hall")] == [
("backlight", "home.brightness", "out", {}),
("dimmer", "home.light", "out", {}),
("gpio.4", "home.doorbell", "in", {"pull": "up"}),
]
log = subprocess.run(
["git", "-C", str(flows.root), "log", "--format=%s"],
capture_output=True,
text=True,
).stdout.splitlines()
assert log[0] == "Move device ports into flows"
# Nothing left to move, and nothing written twice.
store.move_io()
assert flows.read_flow("home").version == 4
assert (
subprocess.run(
["git", "-C", str(flows.root), "log", "--format=%s"],
capture_output=True,
text=True,
).stdout.splitlines()
== log
)
def test_a_crash_after_the_flows_were_written_adds_nothing_twice(
tmp_path: Path,
) -> None:
flows = FlowStore(tmp_path / "flows")
_flow(flows, "home", inputs=[{"spec": {"name": "level", "dtype": "int"}}])
devices = flows.root / "_devices"
devices.mkdir()
device = {
"id": "hall",
"io": [{"port": "backlight", "message": "home.level", "direction": "out"}],
}
(devices / "hall.json").write_text(json.dumps(device))
store = DeviceStore(flows, tmp_path / "devices.json")
store.move_io()
# As if the device file had not been rewritten before the process died.
(devices / "hall.json").write_text(json.dumps(device))
store.move_io()
node = next(n for n in flows.read_flow("home").nodes if n.type == "device")
assert len(node.requires) == 1
assert flows.read_flow("home").version == 2
def test_panels_with_io_reach_the_flows_in_one_start(tmp_path: Path) -> None:
"""An instance from before devices: panels.json, then device nodes."""
flows = FlowStore(tmp_path / "flows")
_flow(flows, "kiosk", inputs=[{"spec": {"name": "brightness", "dtype": "int"}}])
panels = tmp_path / "panels.json"
panels.write_text(
json.dumps(
{
"panels": [
{
"id": "knob",
"io": [
{
"port": "backlight",
"message": "kiosk.brightness",
"direction": "out",
"config": {"wake_s": 30},
}
],
}
]
}
)
)
store = DeviceStore(flows, tmp_path / "devices.json")
store.migrate(panels)
store.move_io()
assert [(r.port, r.message, r.config) for r in store.io("knob")] == [
("backlight", "kiosk.brightness", {"wake_s": 30})
]
def test_the_nonces_are_read_again_only_when_the_file_changes(tmp_path: Path) -> None:
"""Every device request reads its nonce, so the file is parsed once per
change, and a change from outside this process is still seen."""
store = DeviceStore(FlowStore(tmp_path / "flows"), tmp_path / "devices.json")
assert store.nonce("hall") == 0
store.bump("hall")
assert store.nonce("hall") == 1
assert store._states() is store._states()
raw = json.loads(store.state_file.read_text())
raw["hall"]["nonce"] = 7
store.state_file.write_text(json.dumps(raw, indent=4))
assert store.nonce("hall") == 7