queryview 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- queryview/__init__.py +0 -0
- queryview/conftest.py +86 -0
- queryview/connect.py +428 -0
- queryview/dashboard_queries.py +75 -0
- queryview/dashboards.py +156 -0
- queryview/drivers/__init__.py +10 -0
- queryview/drivers/base.py +165 -0
- queryview/drivers/clickhouse.py +138 -0
- queryview/drivers/duckdb.py +153 -0
- queryview/drivers/postgres.py +166 -0
- queryview/drivers/test_base.py +49 -0
- queryview/drivers/test_clickhouse.py +53 -0
- queryview/drivers/test_contract.py +77 -0
- queryview/drivers/test_duckdb.py +80 -0
- queryview/drivers/test_postgres.py +80 -0
- queryview/gitsync.py +374 -0
- queryview/main.py +740 -0
- queryview/mcp_server.py +294 -0
- queryview/migrations/env.py +39 -0
- queryview/migrations/script.py.mako +29 -0
- queryview/migrations/versions/9a536b7c0328_initial_schema.py +89 -0
- queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +59 -0
- queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +32 -0
- queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +98 -0
- queryview/queries.py +159 -0
- queryview/remote.py +141 -0
- queryview/static/assets/index-CvnC_D68.js +47 -0
- queryview/static/assets/index-Qe7bhycG.css +2 -0
- queryview/static/favicon.svg +1 -0
- queryview/static/index.html +14 -0
- queryview/test_api_db.py +51 -0
- queryview/test_api_export_import.py +85 -0
- queryview/test_api_gitsync.py +83 -0
- queryview/test_api_workspaces.py +44 -0
- queryview/test_connect_flow.py +123 -0
- queryview/test_connect_store.py +34 -0
- queryview/test_dashboards.py +216 -0
- queryview/test_gitsync.py +346 -0
- queryview/test_main.py +18 -0
- queryview/test_mcp_gitsync.py +72 -0
- queryview/test_migrations.py +99 -0
- queryview/test_queries.py +170 -0
- queryview/test_remote.py +260 -0
- queryview/test_validation.py +87 -0
- queryview/test_workspaces.py +109 -0
- queryview/test_yamlio.py +198 -0
- queryview/validation.py +111 -0
- queryview/workspaces.py +167 -0
- queryview/yamlio.py +245 -0
- queryview-0.0.2.dist-info/METADATA +183 -0
- queryview-0.0.2.dist-info/RECORD +54 -0
- queryview-0.0.2.dist-info/WHEEL +4 -0
- queryview-0.0.2.dist-info/entry_points.txt +3 -0
- queryview-0.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Alembic owns the schema: a fresh DB must be migrated to head (all three
|
|
2
|
+
tables present and stamped in alembic_version), not built by create_all."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import os
|
|
8
|
+
import sqlite3
|
|
9
|
+
|
|
10
|
+
from queryview.connect import _ensure_schema
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _run(coro):
|
|
14
|
+
return asyncio.run(coro)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_fresh_db_is_migrated_to_head():
|
|
18
|
+
_run(_ensure_schema())
|
|
19
|
+
|
|
20
|
+
con = sqlite3.connect(os.environ["DB_PATH"])
|
|
21
|
+
try:
|
|
22
|
+
names = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
23
|
+
# Alembic ran (not create_all): the version table exists alongside the
|
|
24
|
+
# three application tables.
|
|
25
|
+
assert {
|
|
26
|
+
"connections",
|
|
27
|
+
"predefined_queries",
|
|
28
|
+
"dashboards",
|
|
29
|
+
"workspaces",
|
|
30
|
+
"alembic_version",
|
|
31
|
+
} <= names, f"missing tables, got {sorted(names)}"
|
|
32
|
+
|
|
33
|
+
versions = [r[0] for r in con.execute("SELECT version_num FROM alembic_version")]
|
|
34
|
+
finally:
|
|
35
|
+
con.close()
|
|
36
|
+
|
|
37
|
+
assert len(versions) == 1 and versions[0], versions
|
|
38
|
+
|
|
39
|
+
# The stamped revision is the latest in the migration tree.
|
|
40
|
+
from alembic.script import ScriptDirectory
|
|
41
|
+
|
|
42
|
+
from queryview.connect import _alembic_config
|
|
43
|
+
|
|
44
|
+
head = ScriptDirectory.from_config(_alembic_config()).get_current_head()
|
|
45
|
+
assert versions[0] == head, f"stamped {versions[0]} != head {head}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_config_blob_migration_backfills_existing_clickhouse_row(tmp_path, monkeypatch):
|
|
49
|
+
"""A row written at the pre-blob revision is rewrapped into an encrypted
|
|
50
|
+
JSON config that decrypts back to the original host/port/user/password.
|
|
51
|
+
Runs on a private DB: downgrading the shared session DB below the
|
|
52
|
+
workspaces revision would collide on the restored global-unique names."""
|
|
53
|
+
import json
|
|
54
|
+
import sqlite3
|
|
55
|
+
|
|
56
|
+
from alembic import command
|
|
57
|
+
|
|
58
|
+
import queryview.connect as _c
|
|
59
|
+
from queryview.connect import _alembic_config, _db_path, _decrypt_str, _encrypt_str
|
|
60
|
+
|
|
61
|
+
monkeypatch.setenv("DB_PATH", str(tmp_path / "blob.db"))
|
|
62
|
+
monkeypatch.setenv("DB_KEY_PATH", str(tmp_path / "blob.db.key"))
|
|
63
|
+
monkeypatch.setattr(_c, "_engine", None)
|
|
64
|
+
monkeypatch.setattr(_c, "_schema_ready", False)
|
|
65
|
+
monkeypatch.setattr(_c, "_key", None)
|
|
66
|
+
|
|
67
|
+
cfg = _alembic_config()
|
|
68
|
+
command.upgrade(cfg, "9a536b7c0328") # the per-column schema, built fresh
|
|
69
|
+
|
|
70
|
+
con = sqlite3.connect(_db_path())
|
|
71
|
+
try:
|
|
72
|
+
con.execute(
|
|
73
|
+
"INSERT INTO connections (name, type, host, port, username, password, "
|
|
74
|
+
"database, last_active_at) VALUES (?,?,?,?,?,?,?,?)",
|
|
75
|
+
("legacy", "clickhouse", "h", 8123, "u", _encrypt_str("pw"), "db", 1),
|
|
76
|
+
)
|
|
77
|
+
con.commit()
|
|
78
|
+
finally:
|
|
79
|
+
con.close()
|
|
80
|
+
|
|
81
|
+
command.upgrade(cfg, "head")
|
|
82
|
+
|
|
83
|
+
con = sqlite3.connect(_db_path())
|
|
84
|
+
try:
|
|
85
|
+
blob = con.execute("SELECT config FROM connections WHERE name='legacy'").fetchone()[0]
|
|
86
|
+
finally:
|
|
87
|
+
con.close()
|
|
88
|
+
data = json.loads(_decrypt_str(blob))
|
|
89
|
+
assert data == {"host": "h", "port": 8123, "username": "u", "password": "pw"}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_predefined_queries_has_presentation_columns():
|
|
93
|
+
_run(_ensure_schema())
|
|
94
|
+
con = sqlite3.connect(os.environ["DB_PATH"])
|
|
95
|
+
try:
|
|
96
|
+
cols = {r[1] for r in con.execute("PRAGMA table_info(predefined_queries)")}
|
|
97
|
+
finally:
|
|
98
|
+
con.close()
|
|
99
|
+
assert {"order_by", "fields"} <= cols, f"missing columns, got {sorted(cols)}"
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Tests for the predefined-queries store: cell_view round-trips through
|
|
2
|
+
save/list, and workspace scoping."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
from queryview.queries import (
|
|
9
|
+
get_predefined_query,
|
|
10
|
+
list_predefined_queries,
|
|
11
|
+
save_predefined_query,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _run(coro):
|
|
16
|
+
return asyncio.run(coro)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_save_and_list_round_trips_cell_view(default_ws_id):
|
|
20
|
+
_run(
|
|
21
|
+
save_predefined_query(
|
|
22
|
+
"cves",
|
|
23
|
+
"clickhouse",
|
|
24
|
+
"SELECT cve_id FROM t",
|
|
25
|
+
cell_view="cve_id:\n type: link\n value: https://nvd.nist.gov/vuln/detail/{cell}\n",
|
|
26
|
+
workspace_id=default_ws_id,
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
rows = _run(list_predefined_queries("clickhouse", default_ws_id))
|
|
30
|
+
row = next(r for r in rows if r["query_name"] == "cves")
|
|
31
|
+
assert row["query"] == "SELECT cve_id FROM t"
|
|
32
|
+
cell_view = row["cell_view"]
|
|
33
|
+
assert cell_view is not None
|
|
34
|
+
assert "nvd.nist.gov" in cell_view
|
|
35
|
+
assert "{cell}" in cell_view
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_save_without_cell_view_lists_as_none(default_ws_id):
|
|
39
|
+
_run(save_predefined_query("plain", "clickhouse", "SELECT 1", workspace_id=default_ws_id))
|
|
40
|
+
rows = _run(list_predefined_queries("clickhouse", default_ws_id))
|
|
41
|
+
row = next(r for r in rows if r["query_name"] == "plain")
|
|
42
|
+
assert row["cell_view"] is None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_upsert_overwrites_cell_view(default_ws_id):
|
|
46
|
+
_run(
|
|
47
|
+
save_predefined_query(
|
|
48
|
+
"u",
|
|
49
|
+
"clickhouse",
|
|
50
|
+
"SELECT 1",
|
|
51
|
+
cell_view="a: {type: link, value: x}",
|
|
52
|
+
workspace_id=default_ws_id,
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
_run(
|
|
56
|
+
save_predefined_query(
|
|
57
|
+
"u",
|
|
58
|
+
"clickhouse",
|
|
59
|
+
"SELECT 1",
|
|
60
|
+
cell_view="b: {type: link, value: y}",
|
|
61
|
+
workspace_id=default_ws_id,
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
rows = _run(list_predefined_queries("clickhouse", default_ws_id))
|
|
65
|
+
row = next(r for r in rows if r["query_name"] == "u")
|
|
66
|
+
cell_view = row["cell_view"]
|
|
67
|
+
assert cell_view is not None
|
|
68
|
+
assert "b:" in cell_view
|
|
69
|
+
assert "a:" not in cell_view
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_clearing_cell_view_persists_null(default_ws_id):
|
|
73
|
+
_run(
|
|
74
|
+
save_predefined_query(
|
|
75
|
+
"c",
|
|
76
|
+
"clickhouse",
|
|
77
|
+
"SELECT 1",
|
|
78
|
+
cell_view="x: {type: link, value: y}",
|
|
79
|
+
workspace_id=default_ws_id,
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
_run(save_predefined_query("c", "clickhouse", "SELECT 1", cell_view=None, workspace_id=default_ws_id))
|
|
83
|
+
rows = _run(list_predefined_queries("clickhouse", default_ws_id))
|
|
84
|
+
row = next(r for r in rows if r["query_name"] == "c")
|
|
85
|
+
assert row["cell_view"] is None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_order_by_and_fields_round_trip(default_ws_id):
|
|
89
|
+
_run(
|
|
90
|
+
save_predefined_query(
|
|
91
|
+
"q1",
|
|
92
|
+
"clickhouse",
|
|
93
|
+
"SELECT 1",
|
|
94
|
+
cell_view=None,
|
|
95
|
+
order_by='[{"name":"id","dir":"DESC"}]',
|
|
96
|
+
fields='["id","name"]',
|
|
97
|
+
workspace_id=default_ws_id,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
rows = _run(list_predefined_queries("clickhouse", default_ws_id))
|
|
101
|
+
row = next(r for r in rows if r["query_name"] == "q1")
|
|
102
|
+
assert row["order_by"] == '[{"name":"id","dir":"DESC"}]'
|
|
103
|
+
assert row["fields"] == '["id","name"]'
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_null_presentation_is_preserved(default_ws_id):
|
|
107
|
+
_run(save_predefined_query("q2", "clickhouse", "SELECT 2", workspace_id=default_ws_id))
|
|
108
|
+
rows = _run(list_predefined_queries("clickhouse", default_ws_id))
|
|
109
|
+
row = next(r for r in rows if r["query_name"] == "q2")
|
|
110
|
+
assert row["order_by"] is None and row["fields"] is None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_same_name_is_distinct_per_workspace(default_ws_id):
|
|
114
|
+
from queryview.workspaces import create_workspace, resolve
|
|
115
|
+
|
|
116
|
+
_run(create_workspace("t3-iso"))
|
|
117
|
+
other = _run(resolve("t3-iso")).id
|
|
118
|
+
_run(save_predefined_query("iso q", "clickhouse", "SELECT 1", workspace_id=default_ws_id))
|
|
119
|
+
_run(save_predefined_query("iso q", "clickhouse", "SELECT 2", workspace_id=other))
|
|
120
|
+
|
|
121
|
+
q_default = _run(get_predefined_query("clickhouse", "iso q", default_ws_id))
|
|
122
|
+
q_other = _run(get_predefined_query("clickhouse", "iso q", other))
|
|
123
|
+
assert q_default is not None and q_default["query"] == "SELECT 1"
|
|
124
|
+
assert q_other is not None and q_other["query"] == "SELECT 2"
|
|
125
|
+
names_default = [r["query_name"] for r in _run(list_predefined_queries("clickhouse", default_ws_id))]
|
|
126
|
+
names_other = [r["query_name"] for r in _run(list_predefined_queries("clickhouse", other))]
|
|
127
|
+
assert "iso q" in names_default and "iso q" in names_other
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_api_unknown_workspace_is_404():
|
|
131
|
+
from fastapi.testclient import TestClient
|
|
132
|
+
|
|
133
|
+
from queryview.main import app
|
|
134
|
+
|
|
135
|
+
c = TestClient(app)
|
|
136
|
+
r = c.get("/api/predefined-queries", params={"workspace": "nope-t3"})
|
|
137
|
+
assert r.status_code == 404
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_mcp_list_queries_parses_presentation(default_ws_id):
|
|
141
|
+
from queryview.mcp_server import list_queries
|
|
142
|
+
|
|
143
|
+
_run(
|
|
144
|
+
save_predefined_query(
|
|
145
|
+
"lq",
|
|
146
|
+
"clickhouse",
|
|
147
|
+
"SELECT 1",
|
|
148
|
+
order_by='[{"name":"id","dir":"ASC"}]',
|
|
149
|
+
fields='["id"]',
|
|
150
|
+
workspace_id=default_ws_id,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
out = _run(list_queries("clickhouse"))
|
|
154
|
+
row = next(r for r in out["queries"] if r["query_name"] == "lq")
|
|
155
|
+
assert row["order_by"] == [{"name": "id", "dir": "ASC"}]
|
|
156
|
+
assert row["fields"] == ["id"]
|
|
157
|
+
assert row["query"] == "SELECT 1"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_columns_to_rows():
|
|
161
|
+
from queryview.mcp_server import _columns_to_rows
|
|
162
|
+
|
|
163
|
+
out = _columns_to_rows({"a": ["1", "2"], "b": ["x", "y"]})
|
|
164
|
+
assert out == {"columns": ["a", "b"], "rows": [["1", "x"], ["2", "y"]]}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_columns_to_rows_empty():
|
|
168
|
+
from queryview.mcp_server import _columns_to_rows
|
|
169
|
+
|
|
170
|
+
assert _columns_to_rows({}) == {"columns": [], "rows": []}
|
queryview/test_remote.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from fastapi.testclient import TestClient
|
|
5
|
+
|
|
6
|
+
from queryview import remote
|
|
7
|
+
from queryview.main import app
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_register_returns_distinct_ids():
|
|
11
|
+
a = remote.register()
|
|
12
|
+
b = remote.register()
|
|
13
|
+
assert a and b and a != b
|
|
14
|
+
remote.unregister(a)
|
|
15
|
+
remote.unregister(b)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_push_to_registered_session_delivers():
|
|
19
|
+
rid = remote.register()
|
|
20
|
+
try:
|
|
21
|
+
ok, msg = remote.push(rid, {"type": "query", "query": "SELECT 1"})
|
|
22
|
+
assert ok is True
|
|
23
|
+
msg_in = asyncio.run(remote.next_message(rid, 1.0))
|
|
24
|
+
assert msg_in == {"type": "query", "query": "SELECT 1"}
|
|
25
|
+
finally:
|
|
26
|
+
remote.unregister(rid)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_push_to_unknown_session_fails():
|
|
30
|
+
ok, msg = remote.push("deadbeef", {"type": "query", "query": "SELECT 1"})
|
|
31
|
+
assert ok is False
|
|
32
|
+
assert "unknown" in msg.lower()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_unregister_makes_push_fail():
|
|
36
|
+
rid = remote.register()
|
|
37
|
+
remote.unregister(rid)
|
|
38
|
+
ok, _ = remote.push(rid, {"type": "query", "query": "SELECT 1"})
|
|
39
|
+
assert ok is False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_next_message_times_out_to_none():
|
|
43
|
+
rid = remote.register()
|
|
44
|
+
try:
|
|
45
|
+
assert asyncio.run(remote.next_message(rid, 0.05)) is None
|
|
46
|
+
finally:
|
|
47
|
+
remote.unregister(rid)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_push_endpoint_requires_query():
|
|
51
|
+
client = TestClient(app)
|
|
52
|
+
r = client.post("/api/remote/push", json={"session_id": "x", "query": ""})
|
|
53
|
+
assert r.status_code == 400
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_push_endpoint_unknown_session_returns_not_delivered():
|
|
57
|
+
client = TestClient(app)
|
|
58
|
+
r = client.post(
|
|
59
|
+
"/api/remote/push",
|
|
60
|
+
json={"session_id": "deadbeef", "query": "SELECT 1"},
|
|
61
|
+
)
|
|
62
|
+
assert r.status_code == 200
|
|
63
|
+
assert r.json()["ok"] is False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_push_endpoint_delivers_to_registered_session():
|
|
67
|
+
|
|
68
|
+
rid = remote.register()
|
|
69
|
+
try:
|
|
70
|
+
client = TestClient(app)
|
|
71
|
+
r = client.post(
|
|
72
|
+
"/api/remote/push",
|
|
73
|
+
json={
|
|
74
|
+
"session_id": rid,
|
|
75
|
+
"query": "SELECT id, name FROM items",
|
|
76
|
+
"limit": 5,
|
|
77
|
+
"order_by": [{"name": "id", "dir": "DESC"}],
|
|
78
|
+
"fields": ["name"],
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
assert r.json()["ok"] is True
|
|
82
|
+
msg = asyncio.run(remote.next_message(rid, 1.0))
|
|
83
|
+
assert msg is not None
|
|
84
|
+
assert msg["type"] == "query"
|
|
85
|
+
assert msg["query"] == "SELECT id, name FROM items"
|
|
86
|
+
assert msg["limit"] == 5
|
|
87
|
+
assert msg["order_by"] == [{"name": "id", "dir": "DESC"}]
|
|
88
|
+
assert msg["fields"] == ["name"]
|
|
89
|
+
finally:
|
|
90
|
+
remote.unregister(rid)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_push_endpoint_forwards_cell_view():
|
|
94
|
+
|
|
95
|
+
rid = remote.register()
|
|
96
|
+
try:
|
|
97
|
+
client = TestClient(app)
|
|
98
|
+
yaml = "source:\n type: custom\n value: <span>{cell}</span>\n"
|
|
99
|
+
r = client.post(
|
|
100
|
+
"/api/remote/push",
|
|
101
|
+
json={"session_id": rid, "query": "SELECT 1", "cell_view": yaml},
|
|
102
|
+
)
|
|
103
|
+
assert r.json()["ok"] is True
|
|
104
|
+
msg = asyncio.run(remote.next_message(rid, 1.0))
|
|
105
|
+
assert msg is not None
|
|
106
|
+
assert msg["cell_view"] == yaml
|
|
107
|
+
finally:
|
|
108
|
+
remote.unregister(rid)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_lock_endpoint_acquire_blocks_push():
|
|
112
|
+
|
|
113
|
+
rid = remote.register()
|
|
114
|
+
try:
|
|
115
|
+
client = TestClient(app)
|
|
116
|
+
r = client.post("/api/remote/lock", json={"session_id": rid, "action": "acquire"})
|
|
117
|
+
assert r.json()["ok"] is True
|
|
118
|
+
ok, msg = remote.push(rid, {"type": "query", "query": "SELECT 1"})
|
|
119
|
+
assert ok is False and msg == "blocked, user editing"
|
|
120
|
+
r = client.post("/api/remote/lock", json={"session_id": rid, "action": "release"})
|
|
121
|
+
assert r.json()["ok"] is True
|
|
122
|
+
ok, _ = remote.push(rid, {"type": "query", "query": "SELECT 1"})
|
|
123
|
+
assert ok is True
|
|
124
|
+
finally:
|
|
125
|
+
remote.unregister(rid)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_lock_endpoint_bad_action_400():
|
|
129
|
+
|
|
130
|
+
rid = remote.register()
|
|
131
|
+
try:
|
|
132
|
+
client = TestClient(app)
|
|
133
|
+
r = client.post("/api/remote/lock", json={"session_id": rid, "action": "nope"})
|
|
134
|
+
assert r.status_code == 400
|
|
135
|
+
finally:
|
|
136
|
+
remote.unregister(rid)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_acquire_blocks_push_then_release_allows():
|
|
140
|
+
rid = remote.register()
|
|
141
|
+
try:
|
|
142
|
+
ok, _ = remote.acquire(rid, "human")
|
|
143
|
+
assert ok is True
|
|
144
|
+
ok, msg = remote.push(rid, {"type": "query", "query": "SELECT 1"})
|
|
145
|
+
assert ok is False and msg == "blocked, user editing"
|
|
146
|
+
remote.release(rid, "human")
|
|
147
|
+
ok, msg = remote.push(rid, {"type": "query", "query": "SELECT 1"})
|
|
148
|
+
assert ok is True and msg == "delivered"
|
|
149
|
+
finally:
|
|
150
|
+
remote.unregister(rid)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_lock_ttl_expiry_allows_push():
|
|
154
|
+
rid = remote.register()
|
|
155
|
+
try:
|
|
156
|
+
remote.acquire(rid, "human")
|
|
157
|
+
# Simulate the heartbeat lapsing: age the lock past its TTL.
|
|
158
|
+
remote._channels[rid].lock_touched = time.monotonic() - (remote.LOCK_TTL_SECONDS + 1)
|
|
159
|
+
ok, msg = remote.push(rid, {"type": "query", "query": "SELECT 1"})
|
|
160
|
+
assert ok is True and msg == "delivered"
|
|
161
|
+
finally:
|
|
162
|
+
remote.unregister(rid)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_push_rejects_invalid_order_by():
|
|
166
|
+
rid = remote.register()
|
|
167
|
+
try:
|
|
168
|
+
ok, msg = remote.push(rid, {"type": "query", "query": "SELECT 1", "order_by": [{"name": "id", "dir": "X"}]})
|
|
169
|
+
assert ok is False and "invalid order_by" in msg
|
|
170
|
+
finally:
|
|
171
|
+
remote.unregister(rid)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_push_rejects_malformed_cell_view():
|
|
175
|
+
rid = remote.register()
|
|
176
|
+
try:
|
|
177
|
+
ok, msg = remote.push(rid, {"type": "query", "query": "SELECT 1", "cell_view": "col: [unclosed"})
|
|
178
|
+
assert ok is False and "invalid cell_view" in msg
|
|
179
|
+
finally:
|
|
180
|
+
remote.unregister(rid)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_release_by_nonowner_is_noop():
|
|
184
|
+
rid = remote.register()
|
|
185
|
+
try:
|
|
186
|
+
remote.acquire(rid, "human")
|
|
187
|
+
remote.release(rid, "agent") # wrong owner: must not clear
|
|
188
|
+
ok, msg = remote.push(rid, {"type": "query", "query": "SELECT 1"})
|
|
189
|
+
assert ok is False and msg == "blocked, user editing"
|
|
190
|
+
finally:
|
|
191
|
+
remote.unregister(rid)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_push_endpoint_blank_cell_view_is_none():
|
|
195
|
+
|
|
196
|
+
rid = remote.register()
|
|
197
|
+
try:
|
|
198
|
+
client = TestClient(app)
|
|
199
|
+
r = client.post(
|
|
200
|
+
"/api/remote/push",
|
|
201
|
+
json={"session_id": rid, "query": "SELECT 1", "cell_view": " "},
|
|
202
|
+
)
|
|
203
|
+
assert r.json()["ok"] is True
|
|
204
|
+
msg = asyncio.run(remote.next_message(rid, 1.0))
|
|
205
|
+
assert msg is not None
|
|
206
|
+
assert msg["cell_view"] is None
|
|
207
|
+
finally:
|
|
208
|
+
remote.unregister(rid)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_session_database_set_and_read():
|
|
212
|
+
rid = remote.register()
|
|
213
|
+
try:
|
|
214
|
+
assert remote.session_database(rid) is None
|
|
215
|
+
assert remote.set_session_database(rid, "acme_db") is True
|
|
216
|
+
assert remote.session_database(rid) == "acme_db"
|
|
217
|
+
finally:
|
|
218
|
+
remote.unregister(rid)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_set_session_database_unknown_session():
|
|
222
|
+
assert remote.set_session_database("deadbeef", "x") is False
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_remote_db_endpoint_sets_channel_database():
|
|
226
|
+
|
|
227
|
+
rid = remote.register()
|
|
228
|
+
try:
|
|
229
|
+
client = TestClient(app)
|
|
230
|
+
r = client.post("/api/remote/db", json={"session_id": rid, "database": "d1"})
|
|
231
|
+
assert r.json()["ok"] is True
|
|
232
|
+
assert remote.session_database(rid) == "d1"
|
|
233
|
+
finally:
|
|
234
|
+
remote.unregister(rid)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_push_query_return_includes_database():
|
|
238
|
+
|
|
239
|
+
from queryview.mcp_server import push_query as mcp_push_query
|
|
240
|
+
|
|
241
|
+
rid = remote.register()
|
|
242
|
+
try:
|
|
243
|
+
remote.set_session_database(rid, "acme_db")
|
|
244
|
+
out = asyncio.run(mcp_push_query(rid, "SELECT 1"))
|
|
245
|
+
assert out["ok"] is True and out["database"] == "acme_db"
|
|
246
|
+
finally:
|
|
247
|
+
remote.unregister(rid)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_session_workspace_report_and_read():
|
|
251
|
+
|
|
252
|
+
rid = remote.register()
|
|
253
|
+
try:
|
|
254
|
+
assert remote.session_workspace(rid) is None
|
|
255
|
+
assert remote.set_session_workspace(rid, "team-a") is True
|
|
256
|
+
assert remote.session_workspace(rid) == "team-a"
|
|
257
|
+
assert remote.set_session_workspace("nope", "x") is False
|
|
258
|
+
assert remote.session_workspace("nope") is None
|
|
259
|
+
finally:
|
|
260
|
+
remote.unregister(rid)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from queryview.validation import MAX_LIMIT, cell_view_error, clamp_paging, presentation_error
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_none_inputs_are_valid():
|
|
5
|
+
assert presentation_error(None, None) is None
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_valid_order_by_and_fields():
|
|
9
|
+
assert presentation_error([{"name": "id", "dir": "DESC"}], ["id", "name"]) is None
|
|
10
|
+
assert presentation_error([{"name": "id", "dir": "asc"}], None) is None # case-insensitive
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_bad_direction_rejected():
|
|
14
|
+
msg = presentation_error([{"name": "id", "dir": "SIDEWAYS"}], None)
|
|
15
|
+
assert msg is not None and "dir" in msg
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_missing_name_rejected():
|
|
19
|
+
assert presentation_error([{"dir": "ASC"}], None) is not None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_backtick_in_name_rejected():
|
|
23
|
+
assert presentation_error([{"name": "a`b", "dir": "ASC"}], None) is not None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_non_list_order_by_rejected():
|
|
27
|
+
assert presentation_error("id DESC", None) is not None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_non_string_field_rejected():
|
|
31
|
+
assert presentation_error(None, ["ok", 3]) is not None
|
|
32
|
+
assert presentation_error(None, ["ok", ""]) is not None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_cell_view_absent_or_empty_is_valid():
|
|
36
|
+
assert cell_view_error(None) is None
|
|
37
|
+
assert cell_view_error("") is None
|
|
38
|
+
assert cell_view_error(" \n") is None
|
|
39
|
+
assert cell_view_error("---\n") is None # empty document
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_cell_view_valid_entries_and_params():
|
|
43
|
+
doc = (
|
|
44
|
+
"cve_id:\n"
|
|
45
|
+
" type: link\n"
|
|
46
|
+
" value: https://nvd.nist.gov/vuln/detail/{cell}\n"
|
|
47
|
+
"status:\n"
|
|
48
|
+
" type: custom\n"
|
|
49
|
+
" value: <strong>{cell}</strong>\n"
|
|
50
|
+
"params:\n"
|
|
51
|
+
" - name: env\n"
|
|
52
|
+
" options: [prod, staging]\n"
|
|
53
|
+
" - name: host\n"
|
|
54
|
+
" options_sql: SELECT DISTINCT host FROM t\n"
|
|
55
|
+
)
|
|
56
|
+
assert cell_view_error(doc) is None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_cell_view_rejects_unparsable_yaml_and_non_mapping():
|
|
60
|
+
assert cell_view_error("col: [unclosed\n") is not None
|
|
61
|
+
assert cell_view_error("- a list\n") is not None
|
|
62
|
+
assert cell_view_error("just a scalar") is not None
|
|
63
|
+
assert cell_view_error(42) is not None # not YAML text at all
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_cell_view_rejects_bad_entries():
|
|
67
|
+
assert cell_view_error("col: not a mapping\n") is not None
|
|
68
|
+
assert cell_view_error("col:\n type: lnik\n value: x\n") is not None # typo'd type
|
|
69
|
+
assert cell_view_error("col:\n type: link\n") is not None # missing value
|
|
70
|
+
assert cell_view_error("col:\n type: link\n value: ''\n") is not None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_cell_view_rejects_bad_params():
|
|
74
|
+
assert cell_view_error("params: notalist\n") is not None
|
|
75
|
+
assert cell_view_error("params:\n - options: [a]\n") is not None # no name
|
|
76
|
+
# options and options_sql are mutually exclusive; one is required
|
|
77
|
+
assert cell_view_error("params:\n - name: p\n") is not None
|
|
78
|
+
assert cell_view_error("params:\n - name: p\n options: [a]\n options_sql: SELECT 1\n") is not None
|
|
79
|
+
assert cell_view_error("params:\n - name: p\n options: []\n") is not None
|
|
80
|
+
assert cell_view_error("params:\n - name: p\n options: [{a: b}]\n") is not None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_clamp_paging():
|
|
84
|
+
assert clamp_paging(50, 10) == (50, 10)
|
|
85
|
+
assert clamp_paging(-5, -5) == (0, 0)
|
|
86
|
+
assert clamp_paging(MAX_LIMIT + 1, 0) == (MAX_LIMIT, 0)
|
|
87
|
+
assert clamp_paging("x", None) == (100, 0)
|