C4: a device's `viewers` (superusers by default, or members) covers the image and video messages its ports publish. Anyone else gets none of their values on the socket, in the catalogue or a flow's state, 403 for their history, for the frames they hold (the last 8 included) and for TURN credentials, no bytes when asking for them, and `bye: forbidden` for a live view. A device credential stays bounded by its dashboards. The device page sets it in its Camera section, and a tile the viewer may not see says "No access to this camera" and asks the camera for nothing. C3: - A volatile upload is held to MAX_FRAME_BYTES (4 MiB, 413 above it, read without extra copies), and so is a frame on a device's socket. - A frame the engine holds in memory is written to the ring directory before a local worker is handed it, so load_artifact finds it. - A socket the bus dropped events for catches up with a snapshot, demand included, and a device's credential is checked again per frame, cached by the flow store revision and its nonce, so a lost event never keeps an unpaired device's frames coming. - A report is at most 64 KB once its named config_schemas are put in place. - A device away for 60 s has its true bool messages set to false. - Refused frames are logged by fixed keys, a frame is named after its message, `fluksio serve` waits 5 s for open connections, a tile with a stream URL creates no demand, the 8-name cap keeps the newest names. - Every container's log rotates (json-file, 3 x 10 MB).
691 lines
23 KiB
Python
691 lines
23 KiB
Python
import asyncio
|
|
from datetime import UTC, datetime
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import func
|
|
from sqlmodel import Session, select
|
|
|
|
from fluksio.api.routes import flows as flows_route
|
|
from fluksio.core.config import settings
|
|
from fluksio.flow.events import event_bus
|
|
from fluksio.models import Run, RunArtifact, RunMetric, RunNode
|
|
|
|
PREFIX = f"{settings.API_V1_STR}/flows"
|
|
|
|
WORKING_NODE = """
|
|
def process():
|
|
return {"reading": 21.5}
|
|
"""
|
|
|
|
BROKEN_NODE = """
|
|
def process():
|
|
raise RuntimeError("boom")
|
|
"""
|
|
|
|
|
|
def a_flow(name: str = "demo") -> dict:
|
|
return {
|
|
"name": name,
|
|
"title": "Demo",
|
|
"nodes": [
|
|
{
|
|
"id": "sensor",
|
|
"type": "python",
|
|
"provides": [{"name": "reading", "dtype": "float"}],
|
|
},
|
|
{
|
|
"id": "logger",
|
|
"type": "python",
|
|
"requires": [{"name": "reading", "dtype": "float"}],
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
def test_flows_require_authentication(client: TestClient) -> None:
|
|
assert client.get(f"{PREFIX}/").status_code == 401
|
|
|
|
|
|
def test_save_then_read_flow(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
response = client.put(
|
|
f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow()
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
response = client.get(f"{PREFIX}/demo", headers=superuser_token_headers)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["definition"]["title"] == "Demo"
|
|
assert {node["id"] for node in body["definition"]["nodes"]} == {"sensor", "logger"}
|
|
|
|
# The time beside a version carries its offset, so no client guesses a zone.
|
|
listed = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()["data"]
|
|
summary = next(flow for flow in listed if flow["name"] == "demo")
|
|
assert datetime.fromisoformat(summary["updated_at"]).tzinfo is not None
|
|
|
|
|
|
def test_name_mismatch_is_rejected(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
response = client.put(
|
|
f"{PREFIX}/other", headers=superuser_token_headers, json=a_flow("demo")
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_broken_node_is_reported_and_siblings_stay_active(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow())
|
|
client.put(
|
|
f"{PREFIX}/demo/nodes/sensor/source",
|
|
headers=superuser_token_headers,
|
|
json={"code": WORKING_NODE},
|
|
)
|
|
|
|
response = client.put(
|
|
f"{PREFIX}/demo/nodes/logger/source",
|
|
headers=superuser_token_headers,
|
|
json={"code": "def process(reading:\n"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "error"
|
|
|
|
statuses = client.get(f"{PREFIX}/demo", headers=superuser_token_headers).json()
|
|
by_id = {node["id"]: node for node in statuses["nodes"]}
|
|
assert by_id["demo.sensor"]["status"] == "active"
|
|
assert by_id["demo.logger"]["status"] == "error"
|
|
|
|
|
|
def test_running_a_flow_produces_values(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow())
|
|
client.put(
|
|
f"{PREFIX}/demo/nodes/sensor/source",
|
|
headers=superuser_token_headers,
|
|
json={"code": WORKING_NODE},
|
|
)
|
|
client.put(
|
|
f"{PREFIX}/demo/nodes/logger/source",
|
|
headers=superuser_token_headers,
|
|
json={"code": "def process(reading):\n return {}\n"},
|
|
)
|
|
|
|
response = client.post(
|
|
f"{PREFIX}/demo/run", headers=superuser_token_headers, json={"inputs": {}}
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["values"]["demo.reading"]["value"] == 21.5
|
|
|
|
|
|
def test_editing_does_not_deploy_until_published(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
saved = client.put(
|
|
f"{PREFIX}/staged", headers=superuser_token_headers, json=a_flow("staged")
|
|
).json()
|
|
assert saved["has_draft"] is True
|
|
# Nothing is running yet, so the engine knows no nodes of this flow.
|
|
assert (
|
|
client.get(f"{PREFIX}/staged/state", headers=superuser_token_headers).json()[
|
|
"nodes"
|
|
]
|
|
== []
|
|
)
|
|
|
|
published = client.post(
|
|
f"{PREFIX}/staged/publish",
|
|
headers=superuser_token_headers,
|
|
json={"version": saved["definition"]["version"]},
|
|
)
|
|
assert published.status_code == 200
|
|
assert published.json()["has_draft"] is False
|
|
assert {
|
|
node["id"]
|
|
for node in client.get(
|
|
f"{PREFIX}/staged/state", headers=superuser_token_headers
|
|
).json()["nodes"]
|
|
} == {"staged.sensor", "staged.logger"}
|
|
|
|
client.delete(f"{PREFIX}/staged", headers=superuser_token_headers)
|
|
|
|
|
|
def test_a_stale_save_is_refused(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
saved = client.put(
|
|
f"{PREFIX}/contested", headers=superuser_token_headers, json=a_flow("contested")
|
|
).json()
|
|
stale = saved["definition"]
|
|
|
|
client.put(
|
|
f"{PREFIX}/contested",
|
|
headers=superuser_token_headers,
|
|
json={**stale, "title": "Mine"},
|
|
)
|
|
|
|
response = client.put(
|
|
f"{PREFIX}/contested",
|
|
headers=superuser_token_headers,
|
|
json={**stale, "title": "Theirs"},
|
|
)
|
|
assert response.status_code == 409
|
|
assert response.json()["detail"]["current_version"] == stale["version"] + 1
|
|
|
|
client.delete(f"{PREFIX}/contested", headers=superuser_token_headers)
|
|
|
|
|
|
def test_discarding_a_draft_restores_what_is_running(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
saved = client.put(
|
|
f"{PREFIX}/reverted", headers=superuser_token_headers, json=a_flow("reverted")
|
|
).json()
|
|
client.post(
|
|
f"{PREFIX}/reverted/publish",
|
|
headers=superuser_token_headers,
|
|
json={"version": saved["definition"]["version"]},
|
|
)
|
|
published = client.get(f"{PREFIX}/reverted", headers=superuser_token_headers).json()
|
|
|
|
client.put(
|
|
f"{PREFIX}/reverted",
|
|
headers=superuser_token_headers,
|
|
json={**published["definition"], "title": "Scratch that"},
|
|
)
|
|
|
|
response = client.post(
|
|
f"{PREFIX}/reverted/discard-draft", headers=superuser_token_headers
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["has_draft"] is False
|
|
assert response.json()["definition"]["title"] == "Demo"
|
|
|
|
client.delete(f"{PREFIX}/reverted", headers=superuser_token_headers)
|
|
|
|
|
|
def test_stopping_a_flow_takes_it_off_the_engine(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
saved = client.put(
|
|
f"{PREFIX}/halted", headers=superuser_token_headers, json=a_flow("halted")
|
|
).json()
|
|
client.post(
|
|
f"{PREFIX}/halted/publish",
|
|
headers=superuser_token_headers,
|
|
json={"version": saved["definition"]["version"]},
|
|
)
|
|
|
|
stopped = client.post(f"{PREFIX}/halted/stop", headers=superuser_token_headers)
|
|
assert stopped.status_code == 200
|
|
assert stopped.json()["enabled"] is False
|
|
# Nothing of it is loaded, so there is nothing to run.
|
|
assert (
|
|
client.post(
|
|
f"{PREFIX}/halted/run", headers=superuser_token_headers, json={"inputs": {}}
|
|
).status_code
|
|
== 409
|
|
)
|
|
|
|
started = client.post(f"{PREFIX}/halted/start", headers=superuser_token_headers)
|
|
assert started.json()["enabled"] is True
|
|
assert (
|
|
client.post(
|
|
f"{PREFIX}/halted/run", headers=superuser_token_headers, json={"inputs": {}}
|
|
).status_code
|
|
== 200
|
|
)
|
|
|
|
client.delete(f"{PREFIX}/halted", headers=superuser_token_headers)
|
|
|
|
|
|
def test_pausing_is_reported_back(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
saved = client.put(
|
|
f"{PREFIX}/held", headers=superuser_token_headers, json=a_flow("held")
|
|
).json()
|
|
client.post(
|
|
f"{PREFIX}/held/publish",
|
|
headers=superuser_token_headers,
|
|
json={"version": saved["definition"]["version"]},
|
|
)
|
|
|
|
assert (
|
|
client.post(f"{PREFIX}/held/pause", headers=superuser_token_headers).status_code
|
|
== 200
|
|
)
|
|
assert (
|
|
client.get(f"{PREFIX}/held", headers=superuser_token_headers).json()["paused"]
|
|
is True
|
|
)
|
|
|
|
client.post(f"{PREFIX}/held/resume", headers=superuser_token_headers)
|
|
assert (
|
|
client.get(f"{PREFIX}/held", headers=superuser_token_headers).json()["paused"]
|
|
is False
|
|
)
|
|
|
|
client.delete(f"{PREFIX}/held", headers=superuser_token_headers)
|
|
|
|
|
|
def test_unconnected_input_is_surfaced(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
flow = a_flow()
|
|
flow["nodes"][0]["provides"] = [] # nothing produces "reading" any more
|
|
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=flow)
|
|
|
|
issues = client.post(
|
|
f"{PREFIX}/demo/validate", headers=superuser_token_headers
|
|
).json()["issues"]
|
|
assert any(issue["code"] == "unconnected_input" for issue in issues)
|
|
|
|
|
|
def test_delete_flow(
|
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
|
) -> None:
|
|
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow())
|
|
# Finished: a flow with a live run refuses to be deleted, which is its own
|
|
# test below.
|
|
db.add(Run(id="run-1", flow="demo", status="ok", created_at=datetime.now(UTC)))
|
|
db.add(RunNode(run_id="run-1", node="sensor"))
|
|
db.add(RunMetric(run_id="run-1", name="loss", step=-1))
|
|
db.add(RunArtifact(run_id="run-1", name="model.pt"))
|
|
db.commit()
|
|
|
|
assert (
|
|
client.delete(f"{PREFIX}/demo", headers=superuser_token_headers).status_code
|
|
== 200
|
|
)
|
|
assert (
|
|
client.get(f"{PREFIX}/demo", headers=superuser_token_headers).status_code == 404
|
|
)
|
|
|
|
# The run outlives the flow: a run record is a record of what ran, and
|
|
# `DELETE /runs?flow=` is the only thing that clears one.
|
|
db.expire_all()
|
|
for model in (Run, RunNode, RunMetric, RunArtifact):
|
|
assert db.exec(select(func.count()).select_from(model)).one() == 1
|
|
assert (
|
|
client.get(
|
|
f"{settings.API_V1_STR}/runs/run-1", headers=superuser_token_headers
|
|
).status_code
|
|
== 200
|
|
)
|
|
|
|
|
|
def test_delete_flow_is_refused_while_a_run_is_live(
|
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
|
) -> None:
|
|
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow())
|
|
db.add(
|
|
Run(id="run-live", flow="demo", status="running", created_at=datetime.now(UTC))
|
|
)
|
|
db.commit()
|
|
|
|
response = client.delete(f"{PREFIX}/demo", headers=superuser_token_headers)
|
|
assert response.status_code == 409
|
|
assert "run-live" in response.json()["detail"]
|
|
# Its driver is still writing node rows against that id, so nothing may go.
|
|
assert (
|
|
client.get(f"{PREFIX}/demo", headers=superuser_token_headers).status_code == 200
|
|
)
|
|
|
|
|
|
def test_node_types_are_listed(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
types = client.get(f"{PREFIX}/node-types", headers=superuser_token_headers).json()
|
|
by_type = {entry["type"]: entry for entry in types}
|
|
assert by_type["python"]["has_source"] is True
|
|
assert "properties" in by_type["mqtt"]["params_schema"]
|
|
|
|
|
|
def test_rename_flow(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow())
|
|
|
|
response = client.post(
|
|
f"{PREFIX}/demo/rename",
|
|
headers=superuser_token_headers,
|
|
json={"new_name": "demo_renamed"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["definition"]["name"] == "demo_renamed"
|
|
|
|
assert (
|
|
client.get(f"{PREFIX}/demo", headers=superuser_token_headers).status_code == 404
|
|
)
|
|
assert (
|
|
client.get(
|
|
f"{PREFIX}/demo_renamed", headers=superuser_token_headers
|
|
).status_code
|
|
== 200
|
|
)
|
|
|
|
# Put it back so the tests that follow find the flow they expect.
|
|
client.post(
|
|
f"{PREFIX}/demo_renamed/rename",
|
|
headers=superuser_token_headers,
|
|
json={"new_name": "demo"},
|
|
)
|
|
|
|
|
|
def test_rename_onto_a_taken_name_is_refused(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow())
|
|
client.put(
|
|
f"{PREFIX}/occupied", headers=superuser_token_headers, json=a_flow("occupied")
|
|
)
|
|
|
|
response = client.post(
|
|
f"{PREFIX}/demo/rename",
|
|
headers=superuser_token_headers,
|
|
json={"new_name": "occupied"},
|
|
)
|
|
assert response.status_code == 409
|
|
|
|
client.delete(f"{PREFIX}/occupied", headers=superuser_token_headers)
|
|
|
|
|
|
def test_rename_rejects_an_invalid_name(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow())
|
|
|
|
response = client.post(
|
|
f"{PREFIX}/demo/rename",
|
|
headers=superuser_token_headers,
|
|
json={"new_name": "Not A Flow Name"},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_a_node_that_raises_answers_with_its_error(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""A manual run fails the way a scheduled one does, not as a 500."""
|
|
saved = client.put(
|
|
f"{PREFIX}/failing", headers=superuser_token_headers, json=a_flow("failing")
|
|
).json()
|
|
client.put(
|
|
f"{PREFIX}/failing/nodes/sensor/source",
|
|
headers=superuser_token_headers,
|
|
json={"code": BROKEN_NODE},
|
|
)
|
|
client.post(
|
|
f"{PREFIX}/failing/publish",
|
|
headers=superuser_token_headers,
|
|
json={"version": saved["definition"]["version"]},
|
|
)
|
|
|
|
response = client.post(
|
|
f"{PREFIX}/failing/nodes/sensor/trigger",
|
|
headers=superuser_token_headers,
|
|
json={"values": {}},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "boom" in response.json()["detail"]
|
|
|
|
client.delete(f"{PREFIX}/failing", headers=superuser_token_headers)
|
|
|
|
|
|
def test_acknowledging_a_failure_is_announced_on_the_bus(
|
|
client: TestClient,
|
|
superuser_token_headers: dict[str, str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Another browser holds the same marker; the event is what clears it."""
|
|
saved = client.put(
|
|
f"{PREFIX}/acked", headers=superuser_token_headers, json=a_flow("acked")
|
|
).json()
|
|
client.post(
|
|
f"{PREFIX}/acked/publish",
|
|
headers=superuser_token_headers,
|
|
json={"version": saved["definition"]["version"]},
|
|
)
|
|
|
|
published: list[dict] = []
|
|
monkeypatch.setattr(event_bus, "publish", published.append)
|
|
response = client.post(
|
|
f"{PREFIX}/acked/nodes/sensor/acknowledge", headers=superuser_token_headers
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
event = next(e for e in published if e["type"] == "node_error_acknowledged")
|
|
assert event["node"] == "acked.sensor"
|
|
|
|
client.delete(f"{PREFIX}/acked", headers=superuser_token_headers)
|
|
|
|
|
|
def test_renaming_a_flow_leaves_nothing_under_the_old_name(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
saved = client.put(
|
|
f"{PREFIX}/movable", headers=superuser_token_headers, json=a_flow("movable")
|
|
).json()
|
|
client.put(
|
|
f"{PREFIX}/movable/nodes/sensor/source",
|
|
headers=superuser_token_headers,
|
|
json={"code": WORKING_NODE},
|
|
)
|
|
client.post(
|
|
f"{PREFIX}/movable/publish",
|
|
headers=superuser_token_headers,
|
|
json={"version": saved["definition"]["version"]},
|
|
)
|
|
client.post(
|
|
f"{PREFIX}/movable/run", headers=superuser_token_headers, json={"inputs": {}}
|
|
)
|
|
|
|
state = client.app.state.flow_controller.state
|
|
assert [key for key in state.keys() if "movable." in key]
|
|
|
|
client.post(
|
|
f"{PREFIX}/movable/rename",
|
|
headers=superuser_token_headers,
|
|
json={"new_name": "moved"},
|
|
)
|
|
|
|
# The values repopulate under the new name on the next run; what the old
|
|
# name left behind would sit there for good.
|
|
assert [key for key in state.keys() if "movable." in key] == []
|
|
|
|
client.delete(f"{PREFIX}/moved", headers=superuser_token_headers)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_the_live_socket_ends_when_its_client_goes(
|
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A departing client ends the stream instead of restarting the read.
|
|
|
|
`receive_text` raises for good once the peer has gone, so reading it again
|
|
spun this loop at full tilt: the event loop stalled, `/health` answered
|
|
503 and one queue getter was orphaned per turn.
|
|
|
|
It is also what made `fluksio serve` unstoppable. A handler that never
|
|
returns is an ASGI task uvicorn never sees finish, and its graceful
|
|
shutdown waits on those with no timeout — so SIGTERM logged "Waiting for
|
|
background tasks to complete" and nothing else, leaving `serve.pid` behind
|
|
because only SIGKILL ended the process. This returning is what ends that.
|
|
"""
|
|
monkeypatch.setattr(
|
|
flows_route,
|
|
"user_from_token",
|
|
lambda session, token, **_: SimpleNamespace(id="someone", is_superuser=True),
|
|
)
|
|
|
|
class Peer:
|
|
"""One frame, then gone — the way a closed tab leaves."""
|
|
|
|
def __init__(self) -> None:
|
|
self.app = client.app
|
|
self.headers: dict[str, str] = {}
|
|
self.frames = ['{"type": "media", "names": []}']
|
|
self.sent: list[str] = []
|
|
|
|
async def accept(self) -> None:
|
|
return None
|
|
|
|
async def send_text(self, text: str) -> None:
|
|
self.sent.append(text)
|
|
|
|
async def receive(self) -> dict[str, object]:
|
|
if self.frames:
|
|
return {"type": "websocket.receive", "text": self.frames.pop(0)}
|
|
return {"type": "websocket.disconnect", "code": 1006}
|
|
|
|
peer = Peer()
|
|
subscribed = len(event_bus._subscribers)
|
|
|
|
await asyncio.wait_for(flows_route.flow_events(peer, token="not-a-jwt"), timeout=5)
|
|
|
|
# The snapshot went out, and the frame in between did not end the stream:
|
|
# reaching the disconnect at all means the second read happened.
|
|
assert peer.sent
|
|
assert peer.frames == []
|
|
assert len(event_bus._subscribers) == subscribed
|
|
# A turn of the loop for the cancellations the handler asked for.
|
|
await asyncio.sleep(0.05)
|
|
parked = [t for t in asyncio.all_tasks() if "Queue.get" in repr(t.get_coro())]
|
|
assert parked == []
|
|
|
|
|
|
def test_saving_a_header_moves_the_node_ports_in_the_draft(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
saved = client.put(
|
|
f"{PREFIX}/header", headers=superuser_token_headers, json=a_flow("header")
|
|
).json()
|
|
|
|
response = client.put(
|
|
f"{PREFIX}/header/nodes/logger/source",
|
|
headers=superuser_token_headers,
|
|
json={"code": "def process(reading, level, gain=2):\n return {}\n"},
|
|
).json()
|
|
|
|
assert [spec["port"] for spec in response["node"]["requires"]] == [
|
|
"reading",
|
|
"level",
|
|
]
|
|
assert response["node"]["params"] == {"gain": 2}
|
|
draft = client.get(f"{PREFIX}/header", headers=superuser_token_headers).json()
|
|
assert draft["definition"]["version"] == response["version"]
|
|
assert response["version"] == saved["definition"]["version"] + 1
|
|
logger = next(n for n in draft["definition"]["nodes"] if n["id"] == "logger")
|
|
assert logger["params"] == {"gain": 2}
|
|
# The editor's autosave: a parameter typed away keeps its port for now.
|
|
paused = client.put(
|
|
f"{PREFIX}/header/nodes/logger/source?drop=false",
|
|
headers=superuser_token_headers,
|
|
json={"code": "def process(reading, gain=2):\n return {}\n"},
|
|
).json()
|
|
assert paused["node"] is None
|
|
|
|
client.delete(f"{PREFIX}/header", headers=superuser_token_headers)
|
|
|
|
|
|
def test_a_shared_node_in_use_is_retired_only_with_detach(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
client.put(
|
|
f"{PREFIX}/shared", headers=superuser_token_headers, json=a_flow("shared")
|
|
)
|
|
client.put(
|
|
f"{PREFIX}/shared/nodes/sensor/source",
|
|
headers=superuser_token_headers,
|
|
json={"code": WORKING_NODE},
|
|
)
|
|
client.post(
|
|
f"{PREFIX}/shared/nodes/sensor/share",
|
|
headers=superuser_token_headers,
|
|
json={"lib_name": "reading"},
|
|
)
|
|
|
|
url = f"{PREFIX}/library/reading"
|
|
assert client.delete(url, headers=superuser_token_headers).status_code == 409
|
|
response = client.delete(f"{url}?detach=true", headers=superuser_token_headers)
|
|
assert response.status_code == 200
|
|
|
|
assert client.get(f"{PREFIX}/library", headers=superuser_token_headers).json() == []
|
|
source = client.get(
|
|
f"{PREFIX}/shared/nodes/sensor/source", headers=superuser_token_headers
|
|
)
|
|
assert source.json()["code"] == WORKING_NODE
|
|
|
|
client.delete(f"{PREFIX}/shared", headers=superuser_token_headers)
|
|
|
|
|
|
def test_a_shared_node_only_the_published_flow_uses_is_kept(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""An unshare in the draft leaves the running flow on the source."""
|
|
client.put(
|
|
f"{PREFIX}/unshared", headers=superuser_token_headers, json=a_flow("unshared")
|
|
)
|
|
shared = client.post(
|
|
f"{PREFIX}/unshared/nodes/sensor/share",
|
|
headers=superuser_token_headers,
|
|
json={"lib_name": "published_reading"},
|
|
).json()
|
|
client.post(
|
|
f"{PREFIX}/unshared/publish",
|
|
headers=superuser_token_headers,
|
|
json={"version": shared["definition"]["version"]},
|
|
)
|
|
client.post(
|
|
f"{PREFIX}/unshared/nodes/sensor/unshare", headers=superuser_token_headers
|
|
)
|
|
|
|
url = f"{PREFIX}/library/published_reading"
|
|
response = client.delete(url, headers=superuser_token_headers)
|
|
assert response.status_code == 409
|
|
assert "unshared.sensor" in response.json()["detail"]
|
|
|
|
client.delete(f"{url}?detach=true", headers=superuser_token_headers)
|
|
client.delete(f"{PREFIX}/unshared", headers=superuser_token_headers)
|
|
|
|
|
|
def test_a_shared_source_write_is_audited(
|
|
client: TestClient,
|
|
superuser_token_headers: dict[str, str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""It goes live at once, so it is recorded like a publish."""
|
|
client.put(
|
|
f"{PREFIX}/audited", headers=superuser_token_headers, json=a_flow("audited")
|
|
)
|
|
client.post(
|
|
f"{PREFIX}/audited/nodes/sensor/share",
|
|
headers=superuser_token_headers,
|
|
json={"lib_name": "audited_reading"},
|
|
)
|
|
|
|
published: list[dict] = []
|
|
monkeypatch.setattr(event_bus, "publish", published.append)
|
|
client.put(
|
|
f"{PREFIX}/audited/nodes/sensor/source",
|
|
headers=superuser_token_headers,
|
|
json={"code": WORKING_NODE},
|
|
)
|
|
|
|
(audit,) = [e for e in published if e["type"] == "audit"]
|
|
assert audit["action"] == "edited shared node 'audited_reading'"
|
|
assert audit["flow"] == "audited"
|
|
|
|
client.delete(
|
|
f"{PREFIX}/library/audited_reading?detach=true",
|
|
headers=superuser_token_headers,
|
|
)
|
|
client.delete(f"{PREFIX}/audited", headers=superuser_token_headers)
|