Give connectors a store that survives restarts, and a way to clear it
`ConnectorNode.store` keeps short strings per connector package in the
engine's database (`connector_state`, migration f8c3e5a91d27), so the nodes of
one package share them and `fluksio serve` needs nothing new: get, set and
delete, at most 64 kB a value and 256 keys a package. A superuser lists the
keys (never the values) and clears them through `/connectors/{node_type}/state`,
or under Stored state in a connector node's panel. Documented in the connector
contract.
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"""Resetting what a connector keeps: a superuser's, named by node type."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.flow.connector import CONTRACT_VERSION, ConnectorNode
|
||||
from fluksio.flow.controller import NODE_TYPES, NodeType
|
||||
|
||||
URL = f"{settings.API_V1_STR}/connectors"
|
||||
|
||||
|
||||
class Bridge(ConnectorNode):
|
||||
contract = CONTRACT_VERSION
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bridge(monkeypatch):
|
||||
monkeypatch.setitem(NODE_TYPES, "test_bridge", NodeType("Bridge", "", Bridge))
|
||||
store = Bridge(name="b").store
|
||||
store.set("cert:10.0.0.2", "PEM")
|
||||
store.set("cert:10.0.0.3", "PEM")
|
||||
yield store
|
||||
store.clear()
|
||||
|
||||
|
||||
def test_a_superuser_lists_and_clears_the_keys(
|
||||
client: TestClient, superuser_token_headers: dict[str, str], bridge
|
||||
) -> None:
|
||||
listed = client.get(f"{URL}/test_bridge/state", headers=superuser_token_headers)
|
||||
assert listed.status_code == 200
|
||||
body = listed.json()
|
||||
assert body["connector"] == "tests"
|
||||
assert [row["key"] for row in body["data"]] == ["cert:10.0.0.2", "cert:10.0.0.3"]
|
||||
assert "value" not in body["data"][0]
|
||||
|
||||
one = client.delete(
|
||||
f"{URL}/test_bridge/state/cert:10.0.0.2", headers=superuser_token_headers
|
||||
)
|
||||
assert one.status_code == 200
|
||||
assert bridge.get("cert:10.0.0.2") is None
|
||||
|
||||
rest = client.delete(f"{URL}/test_bridge/state", headers=superuser_token_headers)
|
||||
assert rest.status_code == 200
|
||||
assert bridge.entries() == []
|
||||
|
||||
|
||||
def test_anyone_else_is_refused(
|
||||
client: TestClient, normal_user_token_headers: dict[str, str], bridge
|
||||
) -> None:
|
||||
for method in ("GET", "DELETE"):
|
||||
response = client.request(
|
||||
method, f"{URL}/test_bridge/state", headers=normal_user_token_headers
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert bridge.get("cert:10.0.0.2") == "PEM"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["python/state", "nothing/state", "..%2F../state"])
|
||||
def test_only_a_connector_has_state(
|
||||
client: TestClient, superuser_token_headers: dict[str, str], path: str
|
||||
) -> None:
|
||||
response = client.get(f"{URL}/{path}", headers=superuser_token_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bridge")
|
||||
def test_a_key_that_is_not_there_is_a_404(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
response = client.delete(
|
||||
f"{URL}/test_bridge/state/cert:nowhere", headers=superuser_token_headers
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,65 @@
|
||||
"""What a connector keeps across restarts: per package, small, and durable."""
|
||||
|
||||
import pytest
|
||||
|
||||
from fluksio.flow import connector_store
|
||||
from fluksio.flow.connector import CONTRACT_VERSION, ConnectorNode
|
||||
from fluksio.flow.connector_store import MAX_VALUE_BYTES, ConnectorStore
|
||||
|
||||
|
||||
class Bridge(ConnectorNode):
|
||||
contract = CONTRACT_VERSION
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store():
|
||||
store = ConnectorStore("store_test")
|
||||
yield store
|
||||
store.clear()
|
||||
|
||||
|
||||
def test_the_nodes_of_one_package_share_its_keys():
|
||||
first, second = Bridge(name="a"), Bridge(name="b")
|
||||
try:
|
||||
first.store.set("cert:10.0.0.2", "PEM")
|
||||
assert first.store.connector == "tests"
|
||||
assert second.store.get("cert:10.0.0.2") == "PEM"
|
||||
assert ConnectorStore("other").get("cert:10.0.0.2") is None
|
||||
finally:
|
||||
first.store.clear()
|
||||
|
||||
|
||||
def test_a_value_survives_a_fresh_store(store):
|
||||
store.set("token", "one")
|
||||
store.set("token", "two")
|
||||
|
||||
again = ConnectorStore("store_test")
|
||||
assert again.get("token") == "two"
|
||||
assert again.delete("token") is True
|
||||
assert again.delete("token") is False
|
||||
assert store.get("token") is None
|
||||
|
||||
|
||||
def test_a_value_or_a_key_count_past_the_limit_is_refused(store, monkeypatch):
|
||||
with pytest.raises(ValueError, match="too large"):
|
||||
store.set("big", "x" * (MAX_VALUE_BYTES + 1))
|
||||
|
||||
monkeypatch.setattr(connector_store, "MAX_KEYS", 2)
|
||||
store.set("one", "1")
|
||||
store.set("two", "2")
|
||||
with pytest.raises(ValueError, match="2 keys"):
|
||||
store.set("three", "3")
|
||||
# Replacing a key that is already there is not a new one.
|
||||
store.set("two", "22")
|
||||
assert [row.key for row in store.entries()] == ["one", "two"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["", "a/b", "a b", "x" * 201])
|
||||
def test_a_key_the_reset_route_could_not_name_is_refused(store, key):
|
||||
with pytest.raises(ValueError, match="not a valid key"):
|
||||
store.set(key, "v")
|
||||
|
||||
|
||||
def test_a_package_name_is_a_name():
|
||||
with pytest.raises(ValueError):
|
||||
ConnectorStore("../flows")
|
||||
Reference in New Issue
Block a user