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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user