Files

423 lines
15 KiB
Python

"""Renaming a flow's or a dashboard's key over HTTP (sections 5.2 and 5.3 of
docs/private/id_refac_rename.md): who may, what a request is answered with,
what waits for it, and what keeps working by id afterwards."""
import threading
import time
import uuid
from pathlib import Path
from typing import Any
import httpx
import pytest
from fastapi.testclient import TestClient
from fluksio.api.routes import oauth
from fluksio.core.config import settings
from fluksio.flow import rename as renaming
from fluksio.main import app
API = settings.API_V1_STR
FLOWS = f"{API}/flows"
DASHBOARDS = f"{API}/dashboards"
DEVICES = f"{API}/devices"
MESSAGES = f"{API}/messages"
TWICE = "def process(tick):\n return {'twice': tick * 2}\n"
@pytest.fixture(autouse=True)
def _journal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A journal of the test's own, and pairing unlimited, as in the device
tests."""
monkeypatch.setattr(settings, "RENAME_JOURNAL_FILE", tmp_path / "rename.json")
for bucket in [b for b in oauth._hits if b.startswith("pair:")]:
del oauth._hits[bucket]
def _key() -> str:
return f"rn_{uuid.uuid4().hex[:8]}"
def _flow(
client: TestClient,
headers: dict[str, str],
key: str,
codes: dict[str, str] | None = None,
**document: Any,
) -> str:
"""A flow under an issued id, its python nodes' *codes* written, and
published; its id."""
made = client.post(f"{FLOWS}/", headers=headers, json={"name": key, **document})
assert made.status_code == 201, made.text
definition = made.json()["definition"]
for node_id, code in (codes or {}).items():
saved = client.put(
f"{FLOWS}/{definition['id']}/nodes/{node_id}/source",
headers=headers,
json={"code": code},
)
assert saved.status_code == 200, saved.text
version = client.get(f"{FLOWS}/{definition['id']}", headers=headers).json()[
"definition"
]["version"]
published = client.post(
f"{FLOWS}/{definition['id']}/publish",
headers=headers,
json={"version": version},
)
assert published.status_code == 200, published.text
return str(definition["id"])
def _hooked(code_node: dict | None = None) -> dict[str, Any]:
"""A webhook putting ``tick`` in, and what reads it."""
nodes: list[dict[str, Any]] = [
{
"id": "hook",
"type": "http",
"params": {"url": "/tick", "method": "POST"},
"provides": [{"name": "tick", "dtype": "float"}],
}
]
if code_node is not None:
nodes.append(code_node)
return {"nodes": nodes}
def _request(
client: TestClient,
headers: dict[str, str],
obj_id: str,
new: str,
kind: str = FLOWS,
**override: Any,
) -> dict[str, Any]:
plan = client.post(
f"{kind}/{obj_id}/rename/plan", headers=headers, json={"new_name": new}
)
assert plan.status_code == 200, plan.text
body = plan.json()
return {
"request_id": uuid.uuid4().hex,
"expected_name": body["name"],
"expected_version": body["version"],
"new_name": new,
"fingerprint": body["fingerprint"],
**override,
}
def _in_thread(call) -> tuple[threading.Thread, list[httpx.Response]]:
answered: list[httpx.Response] = []
thread = threading.Thread(target=lambda: answered.append(call()), daemon=True)
thread.start()
return thread, answered
def _until(check, timeout: float = 20.0) -> None:
deadline = time.monotonic() + timeout
while not check():
assert time.monotonic() < deadline, "timed out"
time.sleep(0.05)
def test_a_rename_is_a_superusers_and_answered_once(
client: TestClient,
superuser_token_headers: dict[str, str],
normal_user_token_headers: dict[str, str],
) -> None:
"""Every refusal by its code, then the rename, the same answer for the
same request, and its webhook where it was, by id."""
su = superuser_token_headers
key, new = _key(), _key()
flow_id = _flow(client, su, key, **_hooked())
try:
body = _request(client, su, flow_id, new)
url = f"{FLOWS}/{flow_id}/rename"
assert (
client.post(url, headers=normal_user_token_headers, json=body).status_code
== 403
)
assert (
client.post(f"{FLOWS}/f_nothere0/rename", headers=su, json=body).status_code
== 404
)
for override, status, code in (
({"new_name": "Not A Key"}, 422, "invalid_name"),
({"expected_version": body["expected_version"] - 1}, 409, "stale"),
({"fingerprint": "sha256:moved"}, 409, "plan_changed"),
):
refused = client.post(url, headers=su, json={**body, **override})
assert (refused.status_code, refused.json()["code"]) == (status, code)
assert "refusals" in refused.json()["plan"]
renamed = client.post(url, headers=su, json=body)
assert renamed.status_code == 200, renamed.text
result = renamed.json()
assert (
result["kind"],
result["id"],
result["old_name"],
result["new_name"],
) == (
"flow",
flow_id,
key,
new,
)
assert result["op"] == body["request_id"]
assert client.post(url, headers=su, json=body).json() == result
again = client.post(url, headers=su, json={**body, "request_id": "other"})
assert (again.status_code, again.json()["code"]) == (409, "stale")
detail = client.get(f"{FLOWS}/{flow_id}", headers=su).json()
assert detail["definition"]["name"] == new
# The webhook is under the id, which the rename left alone.
hooked = client.post(f"/hooks/{flow_id}/tick", json={"tick": 7.0})
assert hooked.status_code == 200, hooked.text
def values() -> dict[str, Any]:
found = client.get(f"{FLOWS}/{flow_id}/state", headers=su).json()
return dict(found["values"])
# Once the cascade it queued has run.
_until(lambda: f"{new}.tick" in values())
assert values()[f"{new}.tick"]["value"] == 7.0
assert f"{key}.tick" not in values()
summary = client.get(f"{API}/observability/summary", headers=su).json()
assert summary["rename"] is None
finally:
client.delete(f"{FLOWS}/{flow_id}", headers=su)
def test_a_devices_credential_follows_the_renamed_flow(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""A message it may send moves with the flow, and renaming its dashboard
leaves the device's list as it was."""
su = superuser_token_headers
key, new, board, device = _key(), _key(), _key(), _key()
flow_id = _flow(
client, su, key, inputs=[{"spec": {"name": "button", "dtype": "float"}}]
)
made = client.post(f"{DASHBOARDS}/", headers=su, json={"name": board}).json()
dash_id = made["id"]
made["widgets"] = [
{"id": "s", "type": "slider", "config": {"target": f"{key}.button"}}
]
saved = client.put(f"{DASHBOARDS}/{dash_id}", headers=su, json=made)
assert saved.status_code == 200, saved.text
client.post(
f"{DASHBOARDS}/{dash_id}/publish",
headers=su,
json={"version": saved.json()["version"]},
)
created = client.post(f"{DEVICES}/{device}", headers=su).json()
client.put(
f"{DEVICES}/{device}",
headers=su,
json={**created, "dashboards": [dash_id]},
)
started = client.post(f"{DEVICES}/pair").json()
client.post(f"{DEVICES}/{device}/pair", headers=su, json={"code": started["code"]})
token = client.get(
f"{DEVICES}/pair/{started['code']}", params={"secret": started["secret"]}
).json()["access_token"]
panel = {"Authorization": f"Bearer {token}"}
def send(name: str) -> int:
return client.post(
f"{MESSAGES}/{name}", headers=panel, json={"value": 1.0}
).status_code
try:
assert send(f"{key}.button") == 200
renamed = client.post(
f"{FLOWS}/{flow_id}/rename",
headers=su,
json=_request(client, su, flow_id, new),
)
assert renamed.status_code == 200, renamed.text
assert renamed.json()["documents"][-1]["id"] == dash_id
assert send(f"{key}.button") == 403
assert send(f"{new}.button") == 200
renamed = client.post(
f"{DASHBOARDS}/{dash_id}/rename",
headers=su,
json=_request(client, su, dash_id, f"{board}_b", kind=DASHBOARDS),
)
assert renamed.status_code == 200, renamed.text
assert client.get(f"{DASHBOARDS}/{dash_id}", headers=su).json()["name"] == (
f"{board}_b"
)
listed = client.get(f"{DEVICES}/{device}", headers=su).json()
assert listed["dashboards"] == [dash_id]
assert send(f"{new}.button") == 200
finally:
client.delete(f"{DEVICES}/{device}", headers=su)
client.delete(f"{DASHBOARDS}/{dash_id}", headers=su)
client.delete(f"{FLOWS}/{flow_id}", headers=su)
def test_what_arrives_during_a_rename_waits_or_is_turned_away(
client: TestClient,
superuser_token_headers: dict[str, str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Saves, publishes, creates, deletes, runs, imports, sync and messages
get 503 while the rename holds the engine, its own flow's webhook too.
Another flow's webhook is taken, and what it sets off runs once the
rename lets go. Reads carry on."""
su = superuser_token_headers
key, new, other_key = _key(), _key(), _key()
flow_id = _flow(client, su, key, **_hooked())
other = _flow(
client,
su,
other_key,
{"double": TWICE},
**_hooked(
{
"id": "double",
"type": "python",
"requires": [{"name": "tick", "dtype": "float"}],
"provides": [{"name": "twice", "dtype": "float"}],
}
),
)
stale = client.get(f"{FLOWS}/{flow_id}", headers=su).json()["definition"]
holding, go = threading.Event(), threading.Event()
begin = renaming.begin
def held_begin(*args: Any, **kwargs: Any) -> Any:
holding.set()
assert go.wait(20)
return begin(*args, **kwargs)
monkeypatch.setattr(renaming, "begin", held_begin)
body = _request(client, su, flow_id, new)
thread, answered = _in_thread(
lambda: client.post(f"{FLOWS}/{flow_id}/rename", headers=su, json=body)
)
try:
assert holding.wait(20)
for method, path, payload in (
("put", f"{FLOWS}/{flow_id}", stale),
("post", f"{FLOWS}/{flow_id}/publish", {"version": stale["version"]}),
("post", f"{FLOWS}/", {"name": _key()}),
("delete", f"{FLOWS}/{other}", None),
("post", f"{API}/runs/flows/{other}", {"inputs": {}}),
("post", f"{API}/runs/import", []),
("post", f"{MESSAGES}/{other_key}.tick", {"value": 1.0}),
):
refused = client.request(method, path, headers=su, json=payload)
assert refused.status_code == 503, (path, refused.text)
assert refused.json()["code"] == "renaming"
assert refused.headers["retry-after"] == "5"
assert (
client.post(f"/hooks/{flow_id}/tick", json={"tick": 1.0}).status_code == 503
)
assert (
client.post(f"/hooks/{other}/tick", json={"tick": 3.0}).status_code == 200
)
assert client.get(f"{FLOWS}/{flow_id}", headers=su).status_code == 200
summary = client.get(f"{API}/observability/summary", headers=su).json()
assert summary["rename"]["phase"] == "prepared"
def doubled() -> Any:
values = client.get(f"{FLOWS}/{other}/state", headers=su).json()["values"]
return values.get(f"{other_key}.twice", {}).get("value")
time.sleep(0.3)
assert doubled() is None
finally:
go.set()
thread.join(30)
assert answered[0].status_code == 200, answered[0].text
try:
_until(lambda: doubled() == 6.0)
# An editor still holding the old document is refused, not saved back.
assert (
client.put(f"{FLOWS}/{flow_id}", headers=su, json=stale).status_code == 409
)
finally:
client.delete(f"{FLOWS}/{other}", headers=su)
client.delete(f"{FLOWS}/{flow_id}", headers=su)
def test_a_rename_waits_for_running_work_and_can_be_called_off(
client: TestClient, superuser_token_headers: dict[str, str], tmp_path: Path
) -> None:
"""It says what it waits for, and called off it changes nothing."""
su = superuser_token_headers
key, new = _key(), _key()
go = tmp_path / "go"
slow = (
"import os, time\n"
"def process():\n"
f" while not os.path.exists({str(go)!r}):\n"
" time.sleep(0.05)\n"
" return {'reading': 1.0}\n"
)
flow_id = _flow(
client,
su,
key,
{"sensor": slow},
nodes=[
{
"id": "sensor",
"type": "python",
"provides": [{"name": "reading", "dtype": "float"}],
}
],
)
trigger = f"{FLOWS}/{flow_id}/nodes/sensor/trigger"
triggering, triggered = _in_thread(
lambda: client.post(trigger, headers=su, json={"values": {}})
)
body = _request(client, su, flow_id, new)
try:
# In, and counted, before the gate closes.
_until(lambda: f"POST {trigger}" in app.state.rename_gate._admitted)
thread, answered = _in_thread(
lambda: client.post(f"{FLOWS}/{flow_id}/rename", headers=su, json=body)
)
def waiting() -> Any:
summary = client.get(f"{API}/observability/summary", headers=su).json()
return summary["rename"]
_until(
lambda: waiting() is not None and waiting()["busy"] == [f"POST {trigger}"]
)
assert waiting()["phase"] == "draining"
assert client.put(f"{FLOWS}/{flow_id}", headers=su, json={}).status_code == 503
cancel = f"{FLOWS}/{flow_id}/rename/{body['request_id']}"
assert (
client.delete(f"{FLOWS}/{flow_id}/rename/other", headers=su).status_code
== 404
)
assert client.delete(cancel, headers=su).status_code == 200
thread.join(30)
assert (answered[0].status_code, answered[0].json()["code"]) == (409, "aborted")
assert waiting() is None
finally:
go.touch()
triggering.join(60)
assert triggered[0].status_code == 200, triggered[0].text
try:
definition = client.get(f"{FLOWS}/{flow_id}", headers=su).json()["definition"]
assert (definition["name"], definition["version"]) == (
key,
body["expected_version"],
)
# The gate is open again, and the same rename now goes through.
renamed = client.post(f"{FLOWS}/{flow_id}/rename", headers=su, json=body)
assert renamed.status_code == 200, renamed.text
finally:
client.delete(f"{FLOWS}/{flow_id}", headers=su)