/search gains a `setting` category, `<node>.<setting>` under its flow with the node type and the value in a new `detail` field, so Ctrl-K finds which node uses a broker or a timeout and opens the node. `setting_text` is the one place that decides what may be said: a reference to a stored secret shows its name (to superusers only, as the secrets list does), a plain value in an `x-secret` field or under a key like password, token or api_key is masked and never indexed, nested credentials in headers or query are masked, long values are clipped. The global search ranks what contains the query as typed ahead of loose spellings before it caps a group, which long setting values would otherwise have crowded out.
193 lines
7.0 KiB
Python
193 lines
7.0 KiB
Python
"""The one index the global search matches against."""
|
|
|
|
from typing import Any
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from fluksio.api.routes.search import MASK, setting_text
|
|
from fluksio.core.config import settings
|
|
|
|
PREFIX = f"{settings.API_V1_STR}/search"
|
|
FLOWS = f"{settings.API_V1_STR}/flows"
|
|
DASHBOARDS = f"{settings.API_V1_STR}/dashboards"
|
|
SECRETS = f"{settings.API_V1_STR}/secrets"
|
|
|
|
|
|
def test_search_requires_authentication(client: TestClient) -> None:
|
|
assert client.get(f"{PREFIX}/").status_code == 401
|
|
|
|
|
|
def test_index_reaches_inside_flows_and_dashboards(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""A node and a widget are the point: neither is on any list endpoint."""
|
|
client.put(
|
|
f"{FLOWS}/searchable",
|
|
headers=superuser_token_headers,
|
|
json={
|
|
"name": "searchable",
|
|
"title": "Searchable",
|
|
"nodes": [{"id": "sensor", "type": "python", "title": "Hall sensor"}],
|
|
},
|
|
)
|
|
client.put(
|
|
f"{DASHBOARDS}/hall",
|
|
headers=superuser_token_headers,
|
|
json={
|
|
"name": "hall",
|
|
"title": "Hall",
|
|
"widgets": [
|
|
{
|
|
"id": "temperature",
|
|
"type": "stat",
|
|
"title": "Temperature",
|
|
"layout": {"lg": {"x": 0, "y": 0, "w": 3, "h": 2}},
|
|
"config": {"message": "hall.temperature", "dtype": "float"},
|
|
}
|
|
],
|
|
"version": 0,
|
|
},
|
|
)
|
|
|
|
entries = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
|
|
# Keyed on the parent too: an id is only unique within the document it is
|
|
# in, and the other suites seed their own `sensor` and `temperature`.
|
|
found = {
|
|
(entry["category"], entry["parent"], entry["name"]): entry for entry in entries
|
|
}
|
|
|
|
assert found[("flow", "", "searchable")]["title"] == "Searchable"
|
|
assert found[("node", "searchable", "sensor")]["title"] == "Hall sensor"
|
|
assert found[("node", "searchable", "sensor")]["kind"] == "python"
|
|
assert found[("dashboard", "", "hall")]["title"] == "Hall"
|
|
assert found[("widget", "hall", "temperature")]["title"] == "Temperature"
|
|
assert found[("widget", "hall", "temperature")]["kind"] == "stat"
|
|
|
|
|
|
def test_index_lists_a_flows_inputs_and_outputs(
|
|
client: TestClient, superuser_token_headers: dict[str, str]
|
|
) -> None:
|
|
"""Named by the qualified message, which is the label the canvas draws."""
|
|
client.put(
|
|
f"{FLOWS}/fit",
|
|
headers=superuser_token_headers,
|
|
json={
|
|
"name": "fit",
|
|
"mode": "batch",
|
|
"inputs": [{"spec": {"name": "epochs", "dtype": "int"}}],
|
|
"outputs": ["accuracy"],
|
|
},
|
|
)
|
|
|
|
entries = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
|
|
found = {(e["category"], e["name"]): e for e in entries if e["parent"] == "fit"}
|
|
|
|
assert found[("input", "fit.epochs")]["title"] == "epochs"
|
|
assert found[("input", "fit.epochs")]["kind"] == "int"
|
|
assert found[("output", "fit.accuracy")]["title"] == "accuracy"
|
|
|
|
|
|
def test_secrets_are_named_only_to_a_superuser(
|
|
client: TestClient,
|
|
superuser_token_headers: dict[str, str],
|
|
normal_user_token_headers: dict[str, str],
|
|
) -> None:
|
|
client.put(
|
|
f"{SECRETS}/broker_password",
|
|
headers=superuser_token_headers,
|
|
json={"value": "hunter2"},
|
|
)
|
|
|
|
def secrets(headers: dict[str, str]) -> set[str]:
|
|
entries = client.get(f"{PREFIX}/", headers=headers).json()
|
|
return {e["name"] for e in entries if e["category"] == "secret"}
|
|
|
|
assert "broker_password" in secrets(superuser_token_headers)
|
|
assert secrets(normal_user_token_headers) == set()
|
|
|
|
|
|
def test_a_setting_never_shows_a_credential() -> None:
|
|
"""The masking rules, one by one: nothing secret reaches the index."""
|
|
plain = {"type": "number"}
|
|
secret = {"type": "string", "x-secret": True}
|
|
|
|
assert setting_text("interval", 5, plain, False) == "5"
|
|
assert setting_text("at_start", True, None, False) == "true"
|
|
assert setting_text("topic", "", plain, False) == ""
|
|
assert setting_text("topic", None, plain, False) == ""
|
|
|
|
# A declared credential: a reference is named for those who may read names,
|
|
# a plain string never appears.
|
|
ref = {"$secret": "ntfy_token"}
|
|
assert setting_text("token", ref, secret, True) == "secret ntfy_token"
|
|
assert setting_text("token", ref, secret, False) == "secret"
|
|
assert setting_text("token", "hunter2", secret, True) == MASK
|
|
|
|
# An undeclared one is judged by its key, and a reference stays a reference.
|
|
assert setting_text("api_key", "hunter2", None, True) == MASK
|
|
assert setting_text("api_key", ref, None, False) == "secret"
|
|
|
|
# Nested, such as a header: what is neutral stays, what is not goes.
|
|
headers: dict[str, Any] = {
|
|
"Accept": "text/plain",
|
|
"Authorization": "Bearer hunter2",
|
|
"X-Api-Key": {"$secret": "key_name"},
|
|
}
|
|
text = setting_text("headers", headers, {"type": "object"}, False)
|
|
assert "hunter2" not in text and "key_name" not in text
|
|
assert '"Accept":"text/plain"' in text
|
|
assert f'"Authorization":"{MASK}"' in text
|
|
assert '"X-Api-Key":"secret"' in text
|
|
|
|
# A long value is clipped, so a document is not an index entry.
|
|
clipped = setting_text("payload", "x" * 500, plain, False)
|
|
assert len(clipped) == 80 and clipped.endswith("\u2026")
|
|
|
|
|
|
def test_index_lists_a_nodes_settings(
|
|
client: TestClient,
|
|
superuser_token_headers: dict[str, str],
|
|
normal_user_token_headers: dict[str, str],
|
|
) -> None:
|
|
"""A setting is found by its node and name, with its value beside it."""
|
|
client.put(
|
|
f"{FLOWS}/tuned",
|
|
headers=superuser_token_headers,
|
|
json={
|
|
"name": "tuned",
|
|
"nodes": [
|
|
{"id": "gain", "type": "python", "params": {"factor": 2.5}},
|
|
{
|
|
"id": "hook",
|
|
"type": "http",
|
|
"params": {
|
|
"url": "/tick",
|
|
"secret": {"$secret": "hook_secret"},
|
|
"headers": {"Authorization": "Bearer s3cr3t-value"},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
)
|
|
|
|
def settings_of(headers: dict[str, str]) -> tuple[dict[str, dict], str]:
|
|
response = client.get(f"{PREFIX}/", headers=headers)
|
|
found = {
|
|
e["name"]: e
|
|
for e in response.json()
|
|
if e["category"] == "setting" and e["parent"] == "tuned"
|
|
}
|
|
return found, response.text
|
|
|
|
found, _ = settings_of(superuser_token_headers)
|
|
assert found["gain.factor"]["detail"] == "2.5"
|
|
assert found["gain.factor"]["kind"] == "python"
|
|
assert found["hook.url"]["detail"] == "/tick"
|
|
assert found["hook.secret"]["detail"] == "secret hook_secret"
|
|
|
|
# Who may not read a secret's name does not get it from here either.
|
|
found, text = settings_of(normal_user_token_headers)
|
|
assert found["hook.secret"]["detail"] == "secret"
|
|
assert "hook_secret" not in text
|
|
assert "s3cr3t-value" not in text
|