feat: one gateway port with path-mounted frontends, markdown chat, collapsible sidebars
This commit is contained in:
+44
-32
@@ -21,9 +21,12 @@ from test_conversations import ScriptedClient, World
|
||||
from beaver_gateway.core.auth import TokenStore
|
||||
from beaver_gateway.core.registry import McpRegistry
|
||||
from beaver_gateway.core.transcript import build_entries
|
||||
from beaver_gateway.frontends.admin import AdminFrontend
|
||||
from beaver_gateway.frontends.admin.frontend import build_app as build_admin
|
||||
from beaver_gateway.frontends.api import ApiFrontend
|
||||
from beaver_gateway.frontends.api.frontend import build_app as build_api
|
||||
from beaver_gateway.frontends.base import GatewayRuntime
|
||||
from beaver_gateway.frontends.base import Frontend, GatewayRuntime
|
||||
from beaver_gateway.frontends.root import build_root_app
|
||||
from beaver_gateway.storage.models import RateLimit, Usage
|
||||
|
||||
TOKEN = "tok"
|
||||
@@ -114,6 +117,11 @@ class Api:
|
||||
transport=ASGITransport(app=self.app), base_url="http://api"
|
||||
)
|
||||
|
||||
def root(self, *frontends: Frontend) -> AsyncClient:
|
||||
return AsyncClient(
|
||||
transport=ASGITransport(app=build_root_app(frontends)), base_url="http://gw"
|
||||
)
|
||||
|
||||
async def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
||||
res = await self.http.get(path, params=params, headers=HEADERS)
|
||||
assert res.status_code == 200, res.text
|
||||
@@ -175,7 +183,7 @@ async def test_usage_groups_by_agent_day_and_model(world: World) -> None:
|
||||
},
|
||||
],
|
||||
)
|
||||
by_agent = await api.get("/api/usage", {"group_by": "agent"})
|
||||
by_agent = await api.get("/usage", {"group_by": "agent"})
|
||||
assert [r["agent"] for r in by_agent["rows"]] == ["a"]
|
||||
assert by_agent["total"] == {
|
||||
"turns": 1,
|
||||
@@ -187,16 +195,16 @@ async def test_usage_groups_by_agent_day_and_model(world: World) -> None:
|
||||
"web_searches": 2,
|
||||
}
|
||||
since = (datetime.now(UTC) - timedelta(days=3)).isoformat()
|
||||
by_day = await api.get("/api/usage", {"group_by": "day", "since": since})
|
||||
by_day = await api.get("/usage", {"group_by": "day", "since": since})
|
||||
assert len(by_day["rows"]) == 2
|
||||
assert by_day["rows"][0]["day"] < by_day["rows"][1]["day"]
|
||||
by_model = await api.get("/api/usage", {"group_by": "model", "since": since})
|
||||
by_model = await api.get("/usage", {"group_by": "model", "since": since})
|
||||
models = {r["model"]: r for r in by_model["rows"]}
|
||||
assert models["claude-opus-5"]["web_searches"] == 2
|
||||
assert models["claude-sonnet-5"]["output"] == 2
|
||||
by_conv = await api.get("/api/usage", {"group_by": "conversation", "since": since})
|
||||
by_conv = await api.get("/usage", {"group_by": "conversation", "since": since})
|
||||
assert {r["conversation"] for r in by_conv["rows"]} == {"c1", "c2"}
|
||||
bad = await api.http.get("/api/usage", params={"group_by": "x"}, headers=HEADERS)
|
||||
bad = await api.http.get("/usage", params={"group_by": "x"}, headers=HEADERS)
|
||||
assert bad.status_code == 400
|
||||
|
||||
|
||||
@@ -234,7 +242,7 @@ async def test_limits_report_latest_window_with_gateway_spend(world: World) -> N
|
||||
}
|
||||
],
|
||||
)
|
||||
out = await api.get("/api/limits")
|
||||
out = await api.get("/limits")
|
||||
windows = {w["window"]: w for w in out["windows"]}
|
||||
assert windows["five_hour"]["utilization"] == 0.9
|
||||
assert windows["five_hour"]["status"] == "allowed_warning"
|
||||
@@ -252,18 +260,18 @@ async def test_memory_tree_and_file(world: World) -> None:
|
||||
(root / "состояние.md").write_text("# state", encoding="utf-8")
|
||||
(root / ".hidden").write_text("x", encoding="utf-8")
|
||||
api = Api(world, memory_root=root)
|
||||
tree = await api.get("/api/memory")
|
||||
tree = await api.get("/memory")
|
||||
names = [n["name"] for n in tree["tree"]]
|
||||
assert names == ["дни", "состояние.md"]
|
||||
assert tree["tree"][0]["children"][0]["path"] == "дни/2026-08-27.md"
|
||||
file = await api.get("/api/memory/file", {"path": "дни/2026-08-27.md"})
|
||||
file = await api.get("/memory/file", {"path": "дни/2026-08-27.md"})
|
||||
assert file["content"] == "# day"
|
||||
escape = await api.http.get(
|
||||
"/api/memory/file", params={"path": "../w.db"}, headers=HEADERS
|
||||
"/memory/file", params={"path": "../w.db"}, headers=HEADERS
|
||||
)
|
||||
assert escape.status_code == 404
|
||||
unset = Api(world)
|
||||
res = await unset.http.get("/api/memory", headers=HEADERS)
|
||||
res = await unset.http.get("/memory", headers=HEADERS)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@@ -291,7 +299,7 @@ async def test_describe_snapshots_open_tools_and_records_rate_limit(
|
||||
ScriptedClient.hold = asyncio.Event()
|
||||
await world.conversations.post(conv, "go")
|
||||
await asyncio.sleep(0.3)
|
||||
described = await api.get(f"/api/conversations/{conv.external_id}")
|
||||
described = await api.get(f"/conversations/{conv.external_id}")
|
||||
turn = described["turn"]
|
||||
assert turn is not None and turn["id"] == described["running_turn"]
|
||||
tools = {t["tool_use_id"]: t for t in turn["tools"]}
|
||||
@@ -299,12 +307,12 @@ async def test_describe_snapshots_open_tools_and_records_rate_limit(
|
||||
assert tools["tu_2"]["parent_tool_use_id"] == "tu_1"
|
||||
ScriptedClient.hold.set()
|
||||
await world.settle(conv, 1)
|
||||
described = await api.get(f"/api/conversations/{conv.external_id}")
|
||||
described = await api.get(f"/conversations/{conv.external_id}")
|
||||
assert described["turn"] is None
|
||||
limits = await api.get("/api/limits")
|
||||
limits = await api.get("/limits")
|
||||
assert limits["windows"][0]["utilization"] == 0.8
|
||||
assert limits["windows"][0]["gateway"]["cost_usd"] == 0.5
|
||||
usage = await api.get("/api/usage", {"group_by": "model"})
|
||||
usage = await api.get("/usage", {"group_by": "model"})
|
||||
assert {r["model"] for r in usage["rows"]} == {"claude-opus-5", "claude-haiku-4-5"}
|
||||
assert usage["total"]["web_searches"] == 1
|
||||
conv = await world.conversations.get(conv.external_id)
|
||||
@@ -321,12 +329,12 @@ async def test_describe_snapshots_open_tools_and_records_rate_limit(
|
||||
model="m",
|
||||
),
|
||||
)
|
||||
history = await api.get(f"/api/conversations/{conv.external_id}/history")
|
||||
history = await api.get(f"/conversations/{conv.external_id}/history")
|
||||
assert [m["role"] for m in history["messages"]] == ["user", "assistant"]
|
||||
entries = await api.get(f"/api/conversations/{conv.external_id}/entries")
|
||||
entries = await api.get(f"/conversations/{conv.external_id}/entries")
|
||||
assert entries["total"] == len(entries["entries"]) == 2
|
||||
page = await api.get(
|
||||
f"/api/conversations/{conv.external_id}/entries", {"limit": 1, "offset": 0}
|
||||
f"/conversations/{conv.external_id}/entries", {"limit": 1, "offset": 0}
|
||||
)
|
||||
assert page["offset"] == 0 and len(page["entries"]) == 1
|
||||
|
||||
@@ -335,22 +343,22 @@ async def test_tokens_and_audit_need_admin_scope(world: World) -> None:
|
||||
api = Api(world)
|
||||
api.store.grant("api-only", "weak", scope="api")
|
||||
weak = {"Authorization": "Bearer weak"}
|
||||
assert (await api.http.get("/api/tokens", headers=weak)).status_code == 403
|
||||
assert (await api.http.get("/tokens", headers=weak)).status_code == 403
|
||||
created = await api.http.post(
|
||||
"/api/tokens", json={"name": "cursor", "scope": "mcp"}, headers=HEADERS
|
||||
"/tokens", json={"name": "cursor", "scope": "mcp"}, headers=HEADERS
|
||||
)
|
||||
assert created.status_code == 201
|
||||
plaintext = created.json()["plaintext"]
|
||||
await api.store.invalidate()
|
||||
identity = await api.store.verify(plaintext)
|
||||
assert identity is not None and identity.scope == "mcp"
|
||||
listed = await api.get("/api/tokens")
|
||||
listed = await api.get("/tokens")
|
||||
assert [t["name"] for t in listed["tokens"]] == ["cursor"]
|
||||
token_id = listed["tokens"][0]["id"]
|
||||
revoked = await api.http.post(f"/api/tokens/{token_id}/revoke", headers=HEADERS)
|
||||
revoked = await api.http.post(f"/tokens/{token_id}/revoke", headers=HEADERS)
|
||||
assert revoked.status_code == 200
|
||||
assert (await api.get("/api/tokens"))["tokens"] == []
|
||||
audit = await api.get("/api/audit")
|
||||
assert (await api.get("/tokens"))["tokens"] == []
|
||||
audit = await api.get("/audit")
|
||||
assert [r["kind"] for r in audit["records"]] == ["token_revoke", "token_create"]
|
||||
|
||||
|
||||
@@ -359,9 +367,12 @@ async def test_admin_login_hands_out_the_ui_bearer(world: World) -> None:
|
||||
ui_dir = world.root / "build"
|
||||
ui_dir.mkdir()
|
||||
(ui_dir / "index.html").write_text("<html>ui</html>", encoding="utf-8")
|
||||
admin = build_admin(api.runtime, token="ui-bearer", ui_dir=ui_dir)
|
||||
admin = AdminFrontend(ui_dir=ui_dir)
|
||||
admin._app = build_admin(api.runtime, token="ui-bearer", ui_dir=ui_dir) # noqa: SLF001
|
||||
api.store.grant("admin-ui", "ui-bearer")
|
||||
http = AsyncClient(transport=ASGITransport(app=admin), base_url="http://admin")
|
||||
api_fe = ApiFrontend()
|
||||
api_fe._app = api.app # noqa: SLF001
|
||||
http = api.root(admin, api_fe)
|
||||
assert (await http.get("/admin/auth/session")).status_code == 401
|
||||
bad = await http.post(
|
||||
"/admin/auth/login", json={"username": "admin", "password": "nope"}
|
||||
@@ -372,16 +383,17 @@ async def test_admin_login_hands_out_the_ui_bearer(world: World) -> None:
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
session = (await http.get("/admin/auth/session")).json()
|
||||
assert session["user"] == "admin" and session["token"] == "ui-bearer"
|
||||
assert session["api_base"] is None
|
||||
assert session == {"user": "admin", "token": "ui-bearer"}
|
||||
spa = await http.get("/admin/conversations/abc")
|
||||
assert spa.status_code == 200 and spa.text == "<html>ui</html>"
|
||||
assert (await http.get("/")).status_code == 307
|
||||
root = await http.get("/")
|
||||
assert root.status_code == 307 and root.headers["location"] == "/admin/"
|
||||
assert (await http.get("/admin")).status_code == 307
|
||||
health = await http.get("/healthz")
|
||||
assert health.json()["frontends"] == ["/admin", "/api"]
|
||||
escape = await http.get("/admin/%2e%2e/pyproject.toml")
|
||||
assert escape.status_code == 200 and escape.text == "<html>ui</html>"
|
||||
me = await api.http.get(
|
||||
"/api/agents", headers={"Authorization": "Bearer ui-bearer"}
|
||||
)
|
||||
me = await api.http.get("/agents", headers={"Authorization": "Bearer ui-bearer"})
|
||||
assert me.status_code == 200
|
||||
assert (await http.post("/admin/auth/logout")).status_code == 204
|
||||
assert (await http.get("/admin/auth/session")).status_code == 401
|
||||
|
||||
Reference in New Issue
Block a user