feat: add admin/internal database API with CRUD, read-only query, and natural-language SQL endpoints
Add a new `/dbapi` router package providing a generic database API over `dataset`, restricted to admin sessions, admin API keys, and the internal gateway key. Includes: - `tables.py`: list all tables and inspect table schemas - `crud.py`: full CRUD operations (GET, POST, PATCH, DELETE) with soft-delete awareness, born-live inserts, `?include_deleted`, `.../restore`, and `?hard=true` purge - `query.py`: validated read-only SELECT execution via sqlglot parsing, classification, and EXPLAIN dry-run; async query jobs with WebSocket streaming via `DbApiJobService` - `nl.py`: natural-language-to-SQL conversion using the platform AI gateway with re-prompting until validation passes Also register `DbApiJobService` and `PubSubService` in the service manager, add `DBAPI_DIR` to config data paths, and force cleartext `http://` connections to HTTP/1.1 in `curl_transport` to fix large request failures against uvicorn's HTTP/1.1-only internal gateway.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,79 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from tests.conftest import run_async
|
||||
|
||||
from devplacepy.services.pubsub.hub import PubSubHub, topic_matches
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
def __init__(self):
|
||||
self.frames = []
|
||||
|
||||
async def send_json(self, frame):
|
||||
self.frames.append(frame)
|
||||
|
||||
|
||||
class DeadSocket:
|
||||
async def send_json(self, frame):
|
||||
raise RuntimeError("socket closed")
|
||||
|
||||
|
||||
def test_topic_matches():
|
||||
assert topic_matches("foo", "foo")
|
||||
assert topic_matches("*", "anything.here")
|
||||
assert topic_matches("foo.*", "foo.bar")
|
||||
assert topic_matches("foo.*", "foo")
|
||||
assert not topic_matches("foo.*", "fob")
|
||||
assert not topic_matches("foo", "bar")
|
||||
|
||||
|
||||
def test_publish_delivers_only_to_matching():
|
||||
hub = PubSubHub()
|
||||
a, b = FakeSocket(), FakeSocket()
|
||||
hub.subscribe("public.x", a)
|
||||
hub.subscribe("public.y", b)
|
||||
delivered = run_async(hub.publish("public.x", {"n": 1}))
|
||||
assert delivered == 1
|
||||
assert a.frames == [{"n": 1}]
|
||||
assert b.frames == []
|
||||
|
||||
|
||||
def test_wildcard_delivery():
|
||||
hub = PubSubHub()
|
||||
a = FakeSocket()
|
||||
hub.subscribe("foo.*", a)
|
||||
run_async(hub.publish("foo.bar", {"k": 1}))
|
||||
run_async(hub.publish("foo", {"k": 2}))
|
||||
run_async(hub.publish("fob", {"k": 3}))
|
||||
assert a.frames == [{"k": 1}, {"k": 2}]
|
||||
|
||||
|
||||
def test_unsubscribe_stops_delivery():
|
||||
hub = PubSubHub()
|
||||
a = FakeSocket()
|
||||
hub.subscribe("t", a)
|
||||
hub.unsubscribe("t", a)
|
||||
assert run_async(hub.publish("t", {})) == 0
|
||||
|
||||
|
||||
def test_drop_socket_removes_all_subscriptions():
|
||||
hub = PubSubHub()
|
||||
a = FakeSocket()
|
||||
hub.subscribe("a", a)
|
||||
hub.subscribe("b", a)
|
||||
hub.drop_socket(a)
|
||||
assert hub.topics() == []
|
||||
|
||||
|
||||
def test_dead_socket_is_dropped_silently():
|
||||
hub = PubSubHub()
|
||||
hub.subscribe("t", DeadSocket())
|
||||
assert run_async(hub.publish("t", {})) == 0
|
||||
|
||||
|
||||
def test_topics_lists_subscriber_counts():
|
||||
hub = PubSubHub()
|
||||
a, b = FakeSocket(), FakeSocket()
|
||||
hub.subscribe("x", a)
|
||||
hub.subscribe("x", b)
|
||||
assert hub.topics() == [{"topic": "x", "subscribers": 2}]
|
||||
@@ -0,0 +1,45 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import set_setting
|
||||
from devplacepy.services.pubsub import policy
|
||||
from devplacepy.services.pubsub.policy import Actor
|
||||
|
||||
ADMIN = Actor("admin", "a", "adm")
|
||||
INTERNAL = Actor("internal", "internal", "internal")
|
||||
USER = Actor("user", "u1", "bob")
|
||||
GUEST = Actor("guest", "", "guest")
|
||||
|
||||
|
||||
def test_valid_topic():
|
||||
assert policy.valid_topic("public.demo")
|
||||
assert policy.valid_topic("user.u1.inbox")
|
||||
assert not policy.valid_topic("bad topic")
|
||||
assert not policy.valid_topic("")
|
||||
|
||||
|
||||
def test_privileged_can_use_any_topic():
|
||||
for actor in (ADMIN, INTERNAL):
|
||||
assert policy.can_subscribe(actor, "anything.here")
|
||||
assert policy.can_publish(actor, "public.x")
|
||||
assert policy.can_subscribe(actor, "*")
|
||||
|
||||
|
||||
def test_user_namespace_rules():
|
||||
assert policy.can_publish(USER, "user.u1.x")
|
||||
assert not policy.can_publish(USER, "user.u2.x")
|
||||
assert not policy.can_publish(USER, "public.x")
|
||||
assert policy.can_subscribe(USER, "public.x")
|
||||
assert policy.can_subscribe(USER, "user.u1.feed")
|
||||
assert not policy.can_subscribe(USER, "user.u2.feed")
|
||||
assert not policy.can_subscribe(USER, "*")
|
||||
|
||||
|
||||
def test_guest_gated_by_setting(local_db):
|
||||
set_setting("pubsub_allow_guests", "0")
|
||||
try:
|
||||
assert not policy.can_subscribe(GUEST, "public.x")
|
||||
set_setting("pubsub_allow_guests", "1")
|
||||
assert policy.can_subscribe(GUEST, "public.x")
|
||||
assert not policy.can_publish(GUEST, "public.x")
|
||||
finally:
|
||||
set_setting("pubsub_allow_guests", "0")
|
||||
Reference in New Issue
Block a user