Restart safety: transient credential checks are 1013/503, not a refusal

- A device credential that cannot be checked now (device store not running,
  nonces unreadable) raises DeviceUnavailable: 503 over HTTP, and the
  /flows/ws handshake and the tunnel stream accept and close with 1013.
  1008 (a 403 before accepting) stays for a revoked or invalid one
- Device files are written through a temporary file, so a read mid-save no
  longer looks like a deleted device
- The first report turns Touch friendly on only for a device paired since
  pairings are noted (DeviceState.paired_at), so devices paired before keep
  the switch they were given
- NodeDef reads `device`/`device_policy` from older clients as the worker
  label, for a release
- Tests: transient vs revoked socket, the touch default, the old worker keys,
  and a restart with a device node and a node_health rule sending no push
This commit is contained in:
Melvin Strobl
2026-10-16 18:18:42 +02:00
parent 4286acadfd
commit 13d23f6baf
11 changed files with 228 additions and 26 deletions
+60
View File
@@ -2,7 +2,9 @@
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.cloud import config as cloud_config
@@ -1053,6 +1055,64 @@ def test_unpairing_a_screen_leaves_its_panel_standing(
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.
+51 -1
View File
@@ -3,8 +3,10 @@
import asyncio
from pathlib import Path
from fluksio.flow.controller import FlowController
from fluksio.flow.alerts import Alert, AlertManager, AlertsConfig, Channel, Rule
from fluksio.flow.controller import HEALTH_FLOOR, FlowController
from fluksio.flow.devices import presence
from fluksio.flow.events import EventBus
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.schemas import FlowDef, FlowInput, NodeDef
from fluksio.flow.store import FlowStore
@@ -115,3 +117,51 @@ def test_an_offline_device_is_a_warning_not_a_fault(tmp_path: Path) -> None:
["hall.panel"],
[("node_degraded", True)],
]
def test_a_device_away_after_a_restart_alerts_nobody(tmp_path: Path) -> None:
"""No push, and degraded on screen only once the floor has passed.
A restart starts every device node without its device, which comes back
within seconds, and a rule on every node's health is what sends phones a
push.
"""
controller = _controller(tmp_path)
events: list[dict] = []
original = controller._publish
controller._publish = lambda event: (events.append(event), original(event)) # type: ignore[method-assign]
sent: list[Alert] = []
alerts = AlertManager(
EventBus(),
AlertsConfig(
channels=[Channel(name="phone", kind="ntfy", config={"topic": "t"})],
rules=[Rule(events=["node_health"], channels=["phone"])],
),
)
async def capture(channel: Channel, alert: Alert) -> None:
sent.append(alert)
alerts.send = capture # type: ignore[method-assign]
async def scenario() -> list[list[str]]:
await controller.reload() # the restart: the device is not back yet
surfaced = [[e.id for e in controller.unhealthy_nodes(health="degraded")]]
presence.opened("wall")
presence.closed("wall") # and it drops again
surfaced.append([e.id for e in controller.unhealthy_nodes(health="degraded")])
controller.loaded["hall.panel"].health_since -= HEALTH_FLOOR
surfaced.append([e.id for e in controller.unhealthy_nodes(health="degraded")])
for event in events:
await alerts.handle(event)
await controller._teardown()
return surfaced
assert asyncio.run(scenario()) == [[], [], ["hall.panel"]]
assert [e["health"] for e in events if e["type"] == "node_health"] == [
"degraded",
"ok",
"degraded",
]
assert sent == []
presence.forget("wall")
+11
View File
@@ -193,3 +193,14 @@ def test_opening_the_store_renames_a_nodes_device_to_worker(store: FlowStore):
"prefer",
)
assert published.version == 2
def test_a_client_from_before_the_rename_still_labels_its_workers():
"""An older `fluksio sync` or SDK sends `device`, read as `worker` for a release."""
node = NodeDef.model_validate(
{"id": "fit", "device": "gpu", "device_policy": "prefer"}
)
assert (node.worker, node.worker_policy) == ("gpu", "prefer")
# Beside the new key, the new key wins.
both = NodeDef.model_validate({"id": "fit", "device": "cpu", "worker": "gpu"})
assert both.worker == "gpu"