feat: deep-agent canvas, live observability, and multi-environment tooling
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
"""Shared test fixtures.
|
||||
|
||||
CRITICAL: tests must NOT touch the dev database. We point every persistence path at
|
||||
a throwaway temp dir *before* any `forge` module imports (the engine, settings, secret
|
||||
store, and Chroma path are all bound at import time from these env vars).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
_TMP = Path(tempfile.mkdtemp(prefix="forge_tests_"))
|
||||
os.environ.setdefault("FORGE_DATABASE_URL", f"sqlite+aiosqlite:///{(_TMP / 'test.db').as_posix()}")
|
||||
os.environ.setdefault("FORGE_CHROMA_PATH", (_TMP / "chroma").as_posix())
|
||||
os.environ.setdefault("FORGE_CHECKPOINT_DB", "memory")
|
||||
os.environ.setdefault("FORGE_SECRET_KEY_FILE", (_TMP / "master.key").as_posix())
|
||||
os.environ.setdefault("FORGE_SEED_DEMO", "false")
|
||||
# Tests hit MockTransport / fake hosts; don't do real DNS in the SSRF guard. The
|
||||
# guard's blocking logic is covered explicitly in test_ssrf.py (explicit policies).
|
||||
os.environ.setdefault("FORGE_EGRESS_BLOCK_PRIVATE", "false")
|
||||
# Auth defaults to ON in the real app; the test suite mostly calls services directly
|
||||
# without tokens, so run permissive. test_auth forces auth_required=True where it matters.
|
||||
os.environ.setdefault("FORGE_AUTH_REQUIRED", "false")
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from forge.db.base import init_db # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _ensure_tables():
|
||||
# create_all is idempotent; cheap to run per-test for an isolated DB state.
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_sse_appstatus():
|
||||
# sse_starlette caches a module-level `should_exit_event` bound to the FIRST event loop it
|
||||
# runs in. pytest-asyncio gives each test a fresh loop, so a *second* streaming test in the
|
||||
# process would await that stale event -> "bound to a different event loop". Reset it per
|
||||
# test so each SSE response recreates the event in its own loop.
|
||||
try:
|
||||
from sse_starlette.sse import AppStatus
|
||||
except ImportError:
|
||||
return
|
||||
AppStatus.should_exit = False
|
||||
AppStatus.should_exit_event = None
|
||||
@@ -0,0 +1,54 @@
|
||||
"""parallel_fanout (Send map) + join aggregation, and the loop node."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from forge.engine.compiler import compile_workflow
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.nodes.flow import loop_factory
|
||||
from forge.services.runtime import make_runtime_ctx
|
||||
|
||||
_FANOUT_WF = {
|
||||
"id": "fan", "version": 1,
|
||||
"state": {
|
||||
"messages": {"type": "list[message]", "reducer": "add_messages"},
|
||||
"items": {"type": "list[json]", "reducer": "last"},
|
||||
"results": {"type": "list[str]", "reducer": "add"},
|
||||
},
|
||||
"entry_node": "start",
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "config": {}},
|
||||
{"id": "fan", "type": "parallel_fanout", "config": {"over": "items", "child_node": "worker", "item_key": "item"}},
|
||||
{"id": "worker", "type": "transform", "config": {"expression": "[item]", "output_key": "results"}},
|
||||
{"id": "join", "type": "join", "config": {"reducer": "concat"}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"source": "start", "target": "fan"},
|
||||
{"source": "worker", "target": "join"},
|
||||
{"source": "join", "target": "end"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_parallel_fanout_maps_over_items():
|
||||
ctx = make_runtime_ctx("t_fan", "p_fan")
|
||||
ctx.checkpointer = InMemorySaver()
|
||||
graph = compile_workflow(_FANOUT_WF, ctx)
|
||||
out = await graph.ainvoke({"items": ["a", "b", "c"]}, {"configurable": {"thread_id": "f1"}})
|
||||
assert sorted(out["results"]) == ["a", "b", "c"] # every item processed in parallel + aggregated
|
||||
|
||||
|
||||
def test_loop_node_counts_and_stops():
|
||||
node = loop_factory({"max_iter": 3, "condition": ""}, CompileContext(tenant_id="t", project_id="p"))
|
||||
s1 = node({"_loop_count": 0})
|
||||
assert s1 == {"_loop_count": 1, "_loop": "continue"}
|
||||
s2 = node({"_loop_count": 2})
|
||||
assert s2 == {"_loop_count": 3, "_loop": "done"} # hit max_iter
|
||||
|
||||
|
||||
def test_loop_condition_stops_early():
|
||||
node = loop_factory({"max_iter": 10, "condition": "keep_going == True"}, CompileContext(tenant_id="t", project_id="p"))
|
||||
assert node({"_loop_count": 0, "keep_going": True})["_loop"] == "continue"
|
||||
assert node({"_loop_count": 0, "keep_going": False})["_loop"] == "done"
|
||||
@@ -0,0 +1,30 @@
|
||||
"""An agent binds each tool NAME to the model at most once.
|
||||
|
||||
Two layers protect the model call: `resolve_tool_ids` de-dups by id (a tool that lives in several
|
||||
tool sets is one record → sent once), and `_dedup_tools_by_name` is the final guard against a
|
||||
name collision from distinct records/sources (tool names aren't unique per project, and the list
|
||||
mixes tools + knowledge + MCP + components). Providers reject a duplicate function name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from forge.nodes.agent_node import _dedup_tools_by_name
|
||||
|
||||
|
||||
def test_dedup_keeps_first_occurrence_by_name():
|
||||
first = SimpleNamespace(name="get_orders")
|
||||
dup = SimpleNamespace(name="get_orders") # different object, same name (two Tool records)
|
||||
other = SimpleNamespace(name="create_order")
|
||||
out = _dedup_tools_by_name([first, other, dup])
|
||||
assert [t.name for t in out] == ["get_orders", "create_order"]
|
||||
assert out[0] is first # the first occurrence wins
|
||||
|
||||
|
||||
def test_dedup_never_drops_unnamed_tools():
|
||||
x = SimpleNamespace() # no .name to key on
|
||||
y = SimpleNamespace()
|
||||
named = SimpleNamespace(name="t")
|
||||
out = _dedup_tools_by_name([x, named, y])
|
||||
assert len(out) == 3
|
||||
@@ -0,0 +1,65 @@
|
||||
"""app_event polling trigger: dispatch a run per NEW item, dedup the rest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import Trigger, Workflow
|
||||
from forge.services.dispatch import _poll_app_event
|
||||
from forge.services.runs import RunService
|
||||
|
||||
_WF = {
|
||||
"id": "wf_ae", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "agent",
|
||||
"nodes": [
|
||||
{"id": "agent", "type": "agent", "config": {"flavor": "agent", "model": "fake:ok"}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "agent", "target": "end"}],
|
||||
}
|
||||
|
||||
_PAYLOAD = {"items": [{"id": "1", "msg": "first"}, {"id": "2", "msg": "second"}]}
|
||||
|
||||
|
||||
async def test_app_event_dispatches_new_then_dedupes(monkeypatch):
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, json=_PAYLOAD)))
|
||||
monkeypatch.setattr("forge.util.http.shared_async_client", lambda: client)
|
||||
|
||||
async with SessionLocal() as s:
|
||||
wf = Workflow(tenant_id="t_ae", project_id="p_ae", name="AE", executable=_WF, status="active")
|
||||
s.add(wf)
|
||||
await s.flush()
|
||||
trig = Trigger(
|
||||
tenant_id="t_ae", project_id="p_ae", workflow_id=wf.id, node_id="evt", kind="app_event",
|
||||
# last_fired_at in the past => not the baseline poll, so items dispatch
|
||||
last_fired_at=datetime.utcnow() - timedelta(minutes=10),
|
||||
config={"poll_url": "https://api.example.com/events", "items_path": "items",
|
||||
"dedupe_key": "id", "message_path": "msg", "interval_minutes": 1},
|
||||
meta={},
|
||||
)
|
||||
s.add(trig)
|
||||
await s.commit()
|
||||
await s.refresh(trig)
|
||||
tid = trig.id
|
||||
|
||||
rs = RunService(checkpointer=InMemorySaver())
|
||||
|
||||
# first poll: both items are new -> 2 dispatched
|
||||
async with SessionLocal() as s:
|
||||
t = await s.get(Trigger, tid)
|
||||
n1 = await _poll_app_event(rs, t)
|
||||
assert n1 == 2
|
||||
|
||||
# second poll: same items -> deduped -> 0 dispatched
|
||||
async with SessionLocal() as s:
|
||||
t = await s.get(Trigger, tid)
|
||||
assert set(t.meta["seen"]) == {"1", "2"} # cursor persisted
|
||||
n2 = await _poll_app_event(rs, t)
|
||||
assert n2 == 0
|
||||
|
||||
await client.aclose()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Auth, RBAC, and team-management tests (in-process ASGI)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from forge.config import settings
|
||||
from forge.main import create_app
|
||||
|
||||
|
||||
def _client() -> httpx.AsyncClient:
|
||||
app = create_app()
|
||||
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test")
|
||||
|
||||
|
||||
def _email() -> str:
|
||||
return f"u{uuid.uuid4().hex[:10]}@example.com"
|
||||
|
||||
|
||||
async def test_register_login_me_flow():
|
||||
async with _client() as c:
|
||||
email = _email()
|
||||
r = await c.post("/v1/auth/register", json={"email": email, "password": "supersecret1"})
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["user"]["role"] == "owner"
|
||||
token = body["access_token"]
|
||||
|
||||
# The same email may own another workspace. If both accounts use the same
|
||||
# credentials, login requires the workspace id to disambiguate them.
|
||||
second = await c.post(
|
||||
"/v1/auth/register",
|
||||
json={"email": email, "password": "supersecret1", "workspace_name": "Second"},
|
||||
)
|
||||
assert second.status_code == 201
|
||||
assert second.json()["user"]["tenant_id"] != body["user"]["tenant_id"]
|
||||
|
||||
# wrong password
|
||||
assert (await c.post("/v1/auth/login", json={"email": email, "password": "nope"})).status_code == 401
|
||||
# correct password
|
||||
assert (
|
||||
await c.post("/v1/auth/login", json={"email": email, "password": "supersecret1"})
|
||||
).status_code == 401
|
||||
r = await c.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": email,
|
||||
"password": "supersecret1",
|
||||
"workspace_id": body["user"]["tenant_id"],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
# me with token
|
||||
r = await c.get("/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
assert r.status_code == 200 and r.json()["email"] == email
|
||||
|
||||
|
||||
async def test_auth_required_blocks_anonymous(monkeypatch):
|
||||
monkeypatch.setattr(settings, "auth_required", True)
|
||||
async with _client() as c:
|
||||
assert (await c.get("/v1/auth/me")).status_code == 401
|
||||
|
||||
|
||||
async def test_rbac_viewer_cannot_manage_team():
|
||||
async with _client() as c:
|
||||
owner_email, viewer_email = _email(), _email()
|
||||
owner = (await c.post("/v1/auth/register", json={"email": owner_email, "password": "ownerpass1"})).json()
|
||||
oh = {"Authorization": f"Bearer {owner['access_token']}"}
|
||||
|
||||
# owner invites a viewer with a password so they can log in
|
||||
r = await c.post("/v1/team/members", json={"email": viewer_email, "role": "viewer", "password": "viewerpass1"}, headers=oh)
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
viewer = (await c.post("/v1/auth/login", json={"email": viewer_email, "password": "viewerpass1"})).json()
|
||||
vh = {"Authorization": f"Bearer {viewer['access_token']}"}
|
||||
|
||||
# viewer is forbidden from listing/managing the team
|
||||
assert (await c.get("/v1/team/members", headers=vh)).status_code == 403
|
||||
# owner can
|
||||
r = await c.get("/v1/team/members", headers=oh)
|
||||
assert r.status_code == 200 and len(r.json()) == 2
|
||||
|
||||
|
||||
async def test_invite_without_password_emails_link_and_can_be_accepted():
|
||||
async with _client() as c:
|
||||
owner_email, invitee_email = _email(), _email()
|
||||
owner = (await c.post("/v1/auth/register", json={"email": owner_email, "password": "ownerpass1"})).json()
|
||||
oh = {"Authorization": f"Bearer {owner['access_token']}"}
|
||||
|
||||
# invite with no password => a pending 'invited' user. SMTP is unconfigured in tests,
|
||||
# so the API hands back a redeemable link instead of emailing it.
|
||||
r = await c.post("/v1/team/members", json={"email": invitee_email, "role": "editor"}, headers=oh)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["status"] == "invited" and body["email_sent"] is False
|
||||
assert "invite=" in body.get("invite_url", "")
|
||||
token = body["invite_url"].split("invite=", 1)[1]
|
||||
|
||||
# can't log in yet - no password set
|
||||
assert (await c.post("/v1/auth/login", json={"email": invitee_email, "password": "whatever1"})).status_code == 401
|
||||
|
||||
# invite-info reflects the pending invite
|
||||
info = await c.get(f"/v1/auth/invite-info?token={token}")
|
||||
assert info.status_code == 200 and info.json()["email"] == invitee_email
|
||||
|
||||
# redeeming sets the invitee's own password and logs them in
|
||||
acc = await c.post("/v1/auth/accept-invite", json={"token": token, "password": "myownpass1"})
|
||||
assert acc.status_code == 200, acc.text
|
||||
assert acc.json()["user"]["email"] == invitee_email
|
||||
assert (await c.post("/v1/auth/login", json={"email": invitee_email, "password": "myownpass1"})).status_code == 200
|
||||
|
||||
# a used invite can't be redeemed again
|
||||
assert (await c.post("/v1/auth/accept-invite", json={"token": token, "password": "another123"})).status_code == 400
|
||||
|
||||
|
||||
async def test_refresh_issues_new_access_token():
|
||||
async with _client() as c:
|
||||
email = _email()
|
||||
body = (await c.post("/v1/auth/register", json={"email": email, "password": "supersecret1"})).json()
|
||||
r = await c.post("/v1/auth/refresh", json={"refresh_token": body["refresh_token"]})
|
||||
assert r.status_code == 200 and "access_token" in r.json()
|
||||
# an access token is not accepted as a refresh token
|
||||
assert (await c.post("/v1/auth/refresh", json={"refresh_token": body["access_token"]})).status_code == 401
|
||||
|
||||
|
||||
async def test_cannot_demote_only_owner():
|
||||
async with _client() as c:
|
||||
email = _email()
|
||||
body = (await c.post("/v1/auth/register", json={"email": email, "password": "ownerpass1"})).json()
|
||||
oh = {"Authorization": f"Bearer {body['access_token']}"}
|
||||
uid = body["user"]["id"]
|
||||
r = await c.patch(f"/v1/team/members/{uid}", json={"role": "viewer"}, headers=oh)
|
||||
assert r.status_code == 400 and "owner" in r.json()["detail"].lower()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""`$each` loop support in JSON body templates (batch many rows in one call).
|
||||
|
||||
Before this, one REST tool call could only build a fixed-shape body, so an agent editing N rows
|
||||
made N calls (slow, context-heavy, and prone to blowing the graph recursion limit). A body
|
||||
template can now carry a `{"$each": "{{input.rows}}", "$as": "row", "$do": {...}}` directive that
|
||||
expands one list-valued arg into a variable-length JSON array - so N edits go out in ONE request.
|
||||
|
||||
Rendering is structural (parse JSON, then walk with render_value): the output is always valid
|
||||
JSON with native types preserved, unlike string-concatenating an array. The path is opt-in on the
|
||||
"$each" marker, so existing string-substitution templates are untouched.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from forge.auth_providers.templates import has_each_directive, render_template, render_value
|
||||
from forge.tools.rest import _build_body
|
||||
|
||||
|
||||
def test_render_value_each_expands_per_item():
|
||||
tmpl = {
|
||||
"$each": "{{input.rows}}",
|
||||
"$as": "row",
|
||||
"$do": {"col": "{{row.editedCol}}", "val": "{{row.editedValue}}"},
|
||||
}
|
||||
rows = [
|
||||
{"editedCol": "unitCost", "editedValue": "96"},
|
||||
{"editedCol": "unitCost", "editedValue": "33"},
|
||||
]
|
||||
out = render_value(tmpl, {"input": {"rows": rows}}, allow_each=True)
|
||||
assert out == [
|
||||
{"col": "unitCost", "val": "96"},
|
||||
{"col": "unitCost", "val": "33"},
|
||||
]
|
||||
|
||||
|
||||
def test_render_value_each_preserves_native_types_and_outer_vars():
|
||||
tmpl = {
|
||||
"orderId": "{{input.orderId}}",
|
||||
"rows": {"$each": "{{input.rows}}", "$as": "r", "$do": {"n": "{{r.n}}"}},
|
||||
}
|
||||
out = render_value(tmpl, {"input": {"orderId": "ORD-001", "rows": [{"n": 1}, {"n": 2}]}}, allow_each=True)
|
||||
# line numbers stay ints (whole-string token preserves native type); orderId interpolated.
|
||||
assert out == {"orderId": "ORD-001", "rows": [{"n": 1}, {"n": 2}]}
|
||||
|
||||
|
||||
def test_render_value_each_missing_yields_empty_list():
|
||||
tmpl = {"$each": "{{input.rows}}", "$as": "row", "$do": {"x": "{{row.x}}"}}
|
||||
assert render_value(tmpl, {"input": {}}, allow_each=True) == []
|
||||
|
||||
|
||||
def test_render_value_each_single_value_treated_as_one_item():
|
||||
tmpl = {"$each": "{{input.row}}", "$as": "row", "$do": {"x": "{{row.x}}"}}
|
||||
assert render_value(tmpl, {"input": {"row": {"x": "only"}}}, allow_each=True) == [{"x": "only"}]
|
||||
|
||||
|
||||
def test_render_value_each_is_opt_in_only():
|
||||
# WITHOUT allow_each (the default for auth token_fetch / data-node payload callers), a dict that
|
||||
# happens to have a "$each" KEY must stay an ordinary object, NOT be reinterpreted as a loop.
|
||||
tmpl = {"$each": "{{input.rows}}", "$as": "row", "$do": {"x": "{{row.x}}"}}
|
||||
out = render_value(tmpl, {"input": {"rows": [{"x": "a"}]}})
|
||||
assert out == {"$each": [{"x": "a"}], "$as": "row", "$do": {"x": None}}
|
||||
|
||||
|
||||
def test_has_each_directive_ignores_literal_string_value():
|
||||
# A directive is a "$each" KEY; the literal text "$each" inside a string value is not one.
|
||||
assert has_each_directive({"note": "use $each to loop", "qty": "{{input.qty}}"}) is False
|
||||
assert has_each_directive({"rows": {"$each": "{{x}}", "$do": {}}}) is True
|
||||
|
||||
|
||||
def test_build_body_literal_dollar_each_in_string_keeps_string_substitution():
|
||||
# A valid-JSON template that merely MENTIONS "$each" in a value must not switch to structural
|
||||
# rendering (which would change token type coercion). The quoted token stays a string "5".
|
||||
body_template = json.dumps({"qty": "{{input.qty}}", "note": "$each is a keyword"})
|
||||
body = _build_body({"body_template": body_template}, [], {"qty": 5}, {})
|
||||
assert body == {"qty": "5", "note": "$each is a keyword"}
|
||||
|
||||
|
||||
def test_render_template_embedded_falsy_values_are_not_dropped():
|
||||
# A falsy-but-real value embedded in a larger string must render literally (0 -> "0"), not be
|
||||
# swallowed to "" - which is what a `_lookup(...) or ""` would wrongly do.
|
||||
assert render_template("qty={{input.qty}}", {"input": {"qty": 0}}) == "qty=0"
|
||||
assert render_template("on={{input.flag}}", {"input": {"flag": False}}) == "on=False"
|
||||
assert render_template("x={{input.missing}}", {"input": {}}) == "x="
|
||||
|
||||
|
||||
def test_build_body_batches_multiple_rows_into_one_body():
|
||||
body_template = json.dumps({
|
||||
"orderId": "{{input.orderId}}",
|
||||
"items": {
|
||||
"$each": "{{input.rows}}",
|
||||
"$as": "row",
|
||||
"$do": {
|
||||
"editedCol": "{{row.editedCol}}",
|
||||
"editedValue": "{{row.editedValue}}",
|
||||
"applyConversion": True,
|
||||
"lineNums": ["{{row.lineNum}}"],
|
||||
"enforcePolicy": False,
|
||||
},
|
||||
},
|
||||
})
|
||||
values = {
|
||||
"orderId": "ORD-001",
|
||||
"rows": [
|
||||
{"editedCol": "unitCost", "editedValue": "96", "lineNum": 1},
|
||||
{"editedCol": "unitCost", "editedValue": "33", "lineNum": 2},
|
||||
],
|
||||
}
|
||||
body = _build_body({"body_template": body_template}, [], values, {})
|
||||
assert body["orderId"] == "ORD-001"
|
||||
assert len(body["items"]) == 2
|
||||
assert body["items"][0] == {
|
||||
"editedCol": "unitCost", "editedValue": "96",
|
||||
"applyConversion": True, "lineNums": [1], "enforcePolicy": False,
|
||||
}
|
||||
assert body["items"][1]["lineNums"] == [2]
|
||||
|
||||
|
||||
def test_build_body_passthrough_rows_and_injects_constants():
|
||||
# Mirrors the live agent input: the model sends items items carrying an lineNums
|
||||
# ARRAY and no constants; the template passes each row's fields through (preserving the array)
|
||||
# and injects the fixed applyConversion/enforcePolicy server-side.
|
||||
body_template = json.dumps({
|
||||
"orderId": "{{input.orderId}}",
|
||||
"items": {
|
||||
"$each": "{{input.items}}",
|
||||
"$as": "row",
|
||||
"$do": {
|
||||
"editedCol": "{{row.editedCol}}",
|
||||
"editedValue": "{{row.editedValue}}",
|
||||
"applyConversion": True,
|
||||
"lineNums": "{{row.lineNums}}",
|
||||
"enforcePolicy": False,
|
||||
},
|
||||
},
|
||||
})
|
||||
values = {
|
||||
"orderId": "ORD-001",
|
||||
"items": [
|
||||
{"editedCol": "unitCost", "editedValue": "777", "lineNums": [1]},
|
||||
{"editedCol": "unitCost", "editedValue": "777", "lineNums": [2]},
|
||||
],
|
||||
}
|
||||
body = _build_body({"body_template": body_template}, [], values, {})
|
||||
assert body["orderId"] == "ORD-001"
|
||||
assert body["items"] == [
|
||||
{"editedCol": "unitCost", "editedValue": "777", "applyConversion": True,
|
||||
"lineNums": [1], "enforcePolicy": False},
|
||||
{"editedCol": "unitCost", "editedValue": "777", "applyConversion": True,
|
||||
"lineNums": [2], "enforcePolicy": False},
|
||||
]
|
||||
|
||||
|
||||
def test_build_body_without_each_is_unchanged():
|
||||
# Legacy unquoted-token template (not valid JSON as text) still uses string substitution and
|
||||
# keeps producing a number for the bare {{token}}.
|
||||
body_template = '{ "orderId": "{{input.orderId}}", "lineNums": [ {{input.lineNum}} ] }'
|
||||
body = _build_body({"body_template": body_template}, [], {"orderId": "Q", "lineNum": 7}, {})
|
||||
assert body == {"orderId": "Q", "lineNums": [7]}
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Email channel parsing and reply construction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.channels import email as email_ch
|
||||
|
||||
# --- email parsing ---
|
||||
|
||||
|
||||
def test_email_parse_provider_dict():
|
||||
p = email_ch.parse_inbound({"from": "Jane <jane@acme.com>", "subject": "Help", "text": " my order is late "})
|
||||
assert p["from_addr"] == "jane@acme.com" and p["from_name"] == "Jane" and p["text"] == "my order is late"
|
||||
|
||||
|
||||
def test_email_parse_raw_mime():
|
||||
raw = b"From: Bob <bob@x.com>\r\nSubject: Hi\r\nMessage-ID: <m1>\r\nContent-Type: text/plain\r\n\r\nHello body\r\n"
|
||||
p = email_ch.parse_inbound(raw)
|
||||
assert p["from_addr"] == "bob@x.com" and "Hello body" in p["text"] and p["message_id"] == "<m1>"
|
||||
|
||||
|
||||
def test_email_reply_threads_subject():
|
||||
msg = email_ch.build_reply(to_addr="a@b.com", subject="Order", body="done", from_addr="bot@x.com", in_reply_to="<m1>")
|
||||
assert msg["Subject"] == "Re: Order" and msg["In-Reply-To"] == "<m1>" and msg["To"] == "a@b.com"
|
||||
@@ -0,0 +1,464 @@
|
||||
"""Channels + HITL + run-cancel + semantic-cache wiring (audit a-i).
|
||||
|
||||
Covers: the semantic_cache middleware (short-circuit on hit), handoff TOCTOU claim +
|
||||
delivery-status gating + chained-interrupt re-open + decision coercion, HITL timeout expiry,
|
||||
run cancel, email HTML fallback + threading, and pluggable webhook
|
||||
signatures. Uses the shared checkpointer pattern (InMemorySaver) so runs can be resumed by id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from forge.channels import email as email_ch
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import HandoffRequest, Run, Workflow
|
||||
from forge.routers import hooks
|
||||
from forge.services.channels import ChannelService
|
||||
from forge.services.dispatch import dispatch_message
|
||||
from forge.services.handoff import HandoffService, coerce_to_allowed_decision
|
||||
from forge.services.runs import RunService, run_control
|
||||
|
||||
# --- workflow fixtures --------------------------------------------------------------------
|
||||
|
||||
_HITL_ONE = {
|
||||
"id": "wf_one", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "hi1",
|
||||
"on_error": {"message": "We'll follow up soon."},
|
||||
"nodes": [
|
||||
{"id": "hi1", "type": "human_input", "config": {"prompt": "Approve?", "allowed_decisions": ["approve", "reject"]}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "hi1", "target": "end"}],
|
||||
}
|
||||
|
||||
_HITL_DEFAULT = {
|
||||
"id": "wf_default", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "hi1",
|
||||
"nodes": [
|
||||
{"id": "hi1", "type": "human_input", "config": {
|
||||
"prompt": "Approve?", "allowed_decisions": ["approve", "reject"],
|
||||
"timeout_default": "reject",
|
||||
}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "hi1", "target": "end"}],
|
||||
}
|
||||
|
||||
_HITL_CHAIN = {
|
||||
"id": "wf_chain", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "hi1",
|
||||
"nodes": [
|
||||
{"id": "hi1", "type": "human_input", "config": {"prompt": "Approve step 1?", "allowed_decisions": ["approve", "reject"]}},
|
||||
{"id": "hi2", "type": "human_input", "config": {"prompt": "Confirm step 2?", "ack_message": "One more step - confirming.", "allowed_decisions": ["approve", "reject"]}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "hi1", "target": "hi2"}, {"source": "hi2", "target": "end"}],
|
||||
}
|
||||
|
||||
|
||||
async def _mk_wf(executable, tenant, project) -> Workflow:
|
||||
async with SessionLocal() as s:
|
||||
wf = Workflow(tenant_id=tenant, project_id=project, name="W", executable=executable, status="active")
|
||||
s.add(wf)
|
||||
await s.commit()
|
||||
await s.refresh(wf)
|
||||
return wf
|
||||
|
||||
|
||||
# --- (a) semantic-cache middleware --------------------------------------------------------
|
||||
|
||||
|
||||
def test_semantic_cache_middleware_registered_and_builds():
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.engine.middleware_compiler import (
|
||||
MW_BUILDERS,
|
||||
_SemanticCacheMiddleware,
|
||||
build_middleware,
|
||||
)
|
||||
|
||||
assert "semantic_cache" in MW_BUILDERS
|
||||
mw = build_middleware([{"type": "semantic_cache", "config": {"threshold": 0.9, "ttl": 60}}],
|
||||
CompileContext(tenant_id="t", project_id="p"))
|
||||
assert len(mw) == 1 and isinstance(mw[0], _SemanticCacheMiddleware)
|
||||
|
||||
|
||||
async def test_semantic_cache_middleware_short_circuits_on_hit(monkeypatch):
|
||||
from langchain.agents.middleware.types import ModelResponse
|
||||
|
||||
from forge.engine.middleware_compiler import _SemanticCacheMiddleware
|
||||
|
||||
calls = {"handler": 0, "store": 0}
|
||||
stored: dict[str, str] = {}
|
||||
|
||||
async def fake_lookup(session, t, p, q, *, scope, threshold, ttl):
|
||||
return stored.get(q.strip().lower())
|
||||
|
||||
async def fake_store(session, t, p, q, a, *, scope):
|
||||
calls["store"] += 1
|
||||
stored[q.strip().lower()] = a
|
||||
|
||||
monkeypatch.setattr("forge.services.semantic_cache.SemanticCacheService.lookup", fake_lookup)
|
||||
monkeypatch.setattr("forge.services.semantic_cache.SemanticCacheService.store", fake_store)
|
||||
|
||||
async def handler(_req):
|
||||
calls["handler"] += 1
|
||||
return ModelResponse(result=[AIMessage(content="We are open 9-5.")])
|
||||
|
||||
mw = _SemanticCacheMiddleware("t", "p", threshold=0.9, ttl=3600, scope="default", min_chars=3)
|
||||
q = "what are your business hours?"
|
||||
|
||||
r1 = await mw.awrap_model_call(SimpleNamespace(messages=[HumanMessage(content=q)]), handler)
|
||||
assert calls == {"handler": 1, "store": 1}
|
||||
assert r1.result[0].content == "We are open 9-5."
|
||||
|
||||
# Same question again -> cache hit -> handler is NOT called, a cached AIMessage is returned.
|
||||
r2 = await mw.awrap_model_call(SimpleNamespace(messages=[HumanMessage(content=q)]), handler)
|
||||
assert calls["handler"] == 1
|
||||
assert isinstance(r2, AIMessage) and "9-5" in r2.content
|
||||
|
||||
|
||||
async def test_semantic_cache_middleware_skips_mid_tool_loop(monkeypatch):
|
||||
from langchain.agents.middleware.types import ModelResponse
|
||||
|
||||
from forge.engine.middleware_compiler import _SemanticCacheMiddleware
|
||||
|
||||
looked_up = []
|
||||
|
||||
async def fake_lookup(session, t, p, q, *, scope, threshold, ttl):
|
||||
looked_up.append(q)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("forge.services.semantic_cache.SemanticCacheService.lookup", fake_lookup)
|
||||
monkeypatch.setattr("forge.services.semantic_cache.SemanticCacheService.store", lambda *a, **k: None)
|
||||
|
||||
async def handler(_req):
|
||||
return ModelResponse(result=[AIMessage(content="x")])
|
||||
|
||||
mw = _SemanticCacheMiddleware("t", "p", threshold=0.9, ttl=3600, scope="default", min_chars=3)
|
||||
# Last message is a tool result (not a fresh human question) -> no lookup / store.
|
||||
from langchain_core.messages import ToolMessage
|
||||
req = SimpleNamespace(messages=[HumanMessage(content="hi"), AIMessage(content=""), ToolMessage(content="42", tool_call_id="c1")])
|
||||
await mw.awrap_model_call(req, handler)
|
||||
assert looked_up == []
|
||||
|
||||
|
||||
async def test_semantic_cache_purge(monkeypatch):
|
||||
from forge.services.semantic_cache import SemanticCacheService
|
||||
|
||||
t, p = "t_purge", "p_purge"
|
||||
async with SessionLocal() as s:
|
||||
await SemanticCacheService.store(s, t, p, "will this expire?", "yes")
|
||||
async with SessionLocal() as s:
|
||||
# ttl<=0 purges everything for the scope; returns the count purged.
|
||||
purged = await SemanticCacheService.purge(s, t, p, ttl=0)
|
||||
assert purged >= 1
|
||||
|
||||
|
||||
# --- (c) decision coercion ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_coerce_to_allowed_decision():
|
||||
allowed = ["approve", "reject"]
|
||||
assert coerce_to_allowed_decision("approve", allowed) == "approve"
|
||||
assert coerce_to_allowed_decision("Yes, go ahead", allowed) == "approve"
|
||||
assert coerce_to_allowed_decision("please approve this refund", allowed) == "approve"
|
||||
assert coerce_to_allowed_decision("no, deny it", allowed) == "reject"
|
||||
# Ambiguous free text fails safe to a negative decision when one is offered.
|
||||
assert coerce_to_allowed_decision("hmm not sure yet", allowed) == "reject"
|
||||
# No allowed list -> passthrough (unchanged behavior).
|
||||
assert coerce_to_allowed_decision("whatever", []) == "whatever"
|
||||
|
||||
|
||||
# --- (b) handoff: TOCTOU claim, coercion end-to-end, chained re-open ----------------------
|
||||
|
||||
|
||||
async def test_handoff_reply_claims_row_toctou():
|
||||
wf = await _mk_wf(_HITL_ONE, "t_toctou", "p_toctou")
|
||||
rs = RunService(checkpointer=InMemorySaver())
|
||||
result = await dispatch_message(rs, tenant_id="t_toctou", project_id="p_toctou", workflow_id=wf.id, text="please")
|
||||
assert result["interrupted"] is True
|
||||
async with SessionLocal() as s:
|
||||
h = await HandoffService.create(
|
||||
s, channel=None, tenant_id="t_toctou", project_id="p_toctou", workflow_id=wf.id,
|
||||
run_id=result["run_id"], thread_id=result["thread_id"], customer="u",
|
||||
customer_message="please", reason="approve?", reply_context={},
|
||||
)
|
||||
out1 = await HandoffService.reply(s, rs, handoff=h, agent_id="a1", message="approve")
|
||||
assert out1["ok"] is True
|
||||
# Second reply on the now-answered handoff is rejected by the atomic claim (no re-resume).
|
||||
out2 = await HandoffService.reply(s, rs, handoff=h, agent_id="a2", message="approve")
|
||||
assert out2["ok"] is False and out2["status"] == "answered"
|
||||
|
||||
|
||||
async def test_handoff_closed_filter_includes_all_terminal_outcomes():
|
||||
tenant_id, project_id = "t_closed_queue", "p_closed_queue"
|
||||
async with SessionLocal() as s:
|
||||
open_item = await HandoffService.create(
|
||||
s, channel=None, tenant_id=tenant_id, project_id=project_id, workflow_id=None,
|
||||
run_id="run-open", thread_id=None, customer="Open", customer_message=None,
|
||||
reason="waiting", reply_context={},
|
||||
)
|
||||
answered = await HandoffService.create(
|
||||
s, channel=None, tenant_id=tenant_id, project_id=project_id, workflow_id=None,
|
||||
run_id="run-answered", thread_id=None, customer="Answered", customer_message=None,
|
||||
reason="done", reply_context={},
|
||||
)
|
||||
failed = await HandoffService.create(
|
||||
s, channel=None, tenant_id=tenant_id, project_id=project_id, workflow_id=None,
|
||||
run_id="run-failed", thread_id=None, customer="Failed", customer_message=None,
|
||||
reason="delivery", reply_context={},
|
||||
)
|
||||
answered.status = "answered"
|
||||
failed.status = "delivery_failed"
|
||||
await s.commit()
|
||||
|
||||
open_rows = await HandoffService.list(s, tenant_id, project_id, status="open")
|
||||
closed_rows = await HandoffService.list(s, tenant_id, project_id, status="closed")
|
||||
|
||||
assert [row.id for row in open_rows] == [open_item.id]
|
||||
assert {row.id for row in closed_rows} == {answered.id, failed.id}
|
||||
|
||||
|
||||
async def test_handoff_reply_coerces_free_text_decision():
|
||||
wf = await _mk_wf(_HITL_ONE, "t_coerce", "p_coerce")
|
||||
rs = RunService(checkpointer=InMemorySaver())
|
||||
result = await dispatch_message(rs, tenant_id="t_coerce", project_id="p_coerce", workflow_id=wf.id, text="please")
|
||||
async with SessionLocal() as s:
|
||||
h = await HandoffService.create(
|
||||
s, channel=None, tenant_id="t_coerce", project_id="p_coerce", workflow_id=wf.id,
|
||||
run_id=result["run_id"], thread_id=result["thread_id"], customer="u",
|
||||
customer_message="please", reason="approve?",
|
||||
reply_context={"_forge_hitl": {"allowed_decisions": ["approve", "reject"]}},
|
||||
)
|
||||
out = await HandoffService.reply(s, rs, handoff=h, agent_id="a1", message="yes, go ahead")
|
||||
assert out["ok"] is True
|
||||
# The free-text "yes, go ahead" was coerced to "approve" before resuming.
|
||||
msgs = out["resume"]["messages"]
|
||||
assert any("[human decision] approve" in str(m.get("content", "")) for m in msgs)
|
||||
|
||||
|
||||
async def test_handoff_reply_reopens_on_chained_interrupt():
|
||||
wf = await _mk_wf(_HITL_CHAIN, "t_chain", "p_chain")
|
||||
rs = RunService(checkpointer=InMemorySaver())
|
||||
result = await dispatch_message(rs, tenant_id="t_chain", project_id="p_chain", workflow_id=wf.id, text="start")
|
||||
assert result["interrupted"] is True
|
||||
async with SessionLocal() as s:
|
||||
ch = await ChannelService.create(s, "t_chain", "p_chain", type_="email", name="E", workflow_id=wf.id)
|
||||
h = await HandoffService.create(
|
||||
s, channel=ch, tenant_id="t_chain", project_id="p_chain", workflow_id=wf.id,
|
||||
run_id=result["run_id"], thread_id=result["thread_id"], customer="u",
|
||||
customer_message="start", reason="step1", reply_context={},
|
||||
)
|
||||
out = await HandoffService.reply(s, rs, handoff=h, agent_id="a1", message="approve")
|
||||
assert out["ok"] is True and out["reinterrupted"] is True
|
||||
assert out["new_handoff_id"]
|
||||
# A fresh open handoff exists for the same run so the next step is actionable.
|
||||
async with SessionLocal() as s:
|
||||
fresh = await s.get(HandoffRequest, out["new_handoff_id"])
|
||||
assert fresh is not None and fresh.status == "open" and fresh.run_id == result["run_id"]
|
||||
|
||||
|
||||
# --- (e) delivery status gates 'answered' -------------------------------------------------
|
||||
|
||||
|
||||
async def test_handoff_failed_send_not_marked_answered(monkeypatch):
|
||||
async def _boom(*_a, **_k):
|
||||
raise RuntimeError("smtp down")
|
||||
|
||||
monkeypatch.setattr(email_ch, "send_reply", _boom)
|
||||
wf = await _mk_wf(_HITL_ONE, "t_fail", "p_fail")
|
||||
rs = RunService(checkpointer=InMemorySaver())
|
||||
result = await dispatch_message(rs, tenant_id="t_fail", project_id="p_fail", workflow_id=wf.id, text="please")
|
||||
async with SessionLocal() as s:
|
||||
ch = await ChannelService.create(s, "t_fail", "p_fail", type_="email", name="E", workflow_id=wf.id)
|
||||
h = await HandoffService.create(
|
||||
s, channel=ch, tenant_id="t_fail", project_id="p_fail", workflow_id=wf.id,
|
||||
run_id=result["run_id"], thread_id=result["thread_id"], customer="c@x.com",
|
||||
customer_message="please", reason="approve?", reply_context={"from_addr": "c@x.com"},
|
||||
)
|
||||
out = await HandoffService.reply(s, rs, handoff=h, agent_id="a1", message="Here you go")
|
||||
assert out["ok"] is False and out["status"] == "delivery_failed"
|
||||
refreshed = await s.get(HandoffRequest, h.id)
|
||||
assert refreshed.status == "delivery_failed" # NOT 'answered' on a failed send
|
||||
|
||||
|
||||
# --- (c) HITL timeout expiry --------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_hitl_timeout_expires_interrupted_run(monkeypatch):
|
||||
delivered: list[str] = []
|
||||
|
||||
async def _rec_deliver(channel, reply_ctx, text):
|
||||
delivered.append(text)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("forge.services.handoff._deliver", _rec_deliver)
|
||||
wf = await _mk_wf(_HITL_ONE, "t_exp", "p_exp")
|
||||
rs = RunService(checkpointer=InMemorySaver())
|
||||
result = await dispatch_message(rs, tenant_id="t_exp", project_id="p_exp", workflow_id=wf.id, text="please")
|
||||
run_id = result["run_id"]
|
||||
async with SessionLocal() as s:
|
||||
ch = await ChannelService.create(s, "t_exp", "p_exp", type_="email", name="E", workflow_id=wf.id)
|
||||
h = await HandoffService.create(
|
||||
s, channel=ch, tenant_id="t_exp", project_id="p_exp", workflow_id=wf.id,
|
||||
run_id=run_id, thread_id=result["thread_id"], customer="u", customer_message="please",
|
||||
reason="approve?", reply_context={"conversation_id": "c1"},
|
||||
)
|
||||
hid = h.id
|
||||
# Backdate the pause so it's past the (tiny) timeout used below.
|
||||
run = await s.get(Run, run_id)
|
||||
run.ended_at = datetime.utcnow() - timedelta(hours=1)
|
||||
await s.commit()
|
||||
|
||||
reaped = await rs.reap_stale_runs(hitl_timeout_s=1)
|
||||
assert reaped >= 1
|
||||
async with SessionLocal() as s:
|
||||
run = await s.get(Run, run_id)
|
||||
assert run.status == "error" and "HITL" in (run.error or "")
|
||||
h = await s.get(HandoffRequest, hid)
|
||||
assert h.status == "closed"
|
||||
# The workflow's on_error fallback was pushed over the channel.
|
||||
assert delivered and delivered[0] == "We'll follow up soon."
|
||||
|
||||
|
||||
async def test_hitl_timeout_resumes_with_configured_default():
|
||||
wf = await _mk_wf(_HITL_DEFAULT, "t_exp_default", "p_exp_default")
|
||||
rs = RunService(checkpointer=InMemorySaver())
|
||||
result = await dispatch_message(
|
||||
rs, tenant_id="t_exp_default", project_id="p_exp_default",
|
||||
workflow_id=wf.id, text="please",
|
||||
)
|
||||
assert result["interrupted"] is True
|
||||
async with SessionLocal() as s:
|
||||
run = await s.get(Run, result["run_id"])
|
||||
run.ended_at = datetime.utcnow() - timedelta(hours=1)
|
||||
await s.commit()
|
||||
|
||||
assert await rs.reap_stale_runs(hitl_timeout_s=1) >= 1
|
||||
async with SessionLocal() as s:
|
||||
run = await s.get(Run, result["run_id"])
|
||||
assert run.status == "done"
|
||||
assert run.error is None
|
||||
|
||||
|
||||
# --- (h) run cancel -----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_cancel_run_marks_canceled_and_is_idempotent():
|
||||
wf = await _mk_wf(_HITL_ONE, "t_cancel", "p_cancel")
|
||||
rs = RunService(checkpointer=InMemorySaver())
|
||||
async with SessionLocal() as s:
|
||||
run = await rs.create_run(s, tenant_id="t_cancel", project_id="p_cancel", workflow_id=wf.id, input={})
|
||||
run_id = run.id
|
||||
out = await rs.cancel_run(run_id=run_id, tenant_id="t_cancel", project_id="p_cancel")
|
||||
assert out["ok"] is True and out["status"] == "canceled"
|
||||
async with SessionLocal() as s:
|
||||
assert (await s.get(Run, run_id)).status == "canceled"
|
||||
# Cancelling an already-terminal run is a no-op.
|
||||
out2 = await rs.cancel_run(run_id=run_id, tenant_id="t_cancel", project_id="p_cancel")
|
||||
assert out2["ok"] is False and out2["status"] == "canceled"
|
||||
|
||||
|
||||
async def test_run_control_observes_cross_worker_database_cancel():
|
||||
"""A DB cancellation made outside the local registry hard-cancels the active task."""
|
||||
wf = await _mk_wf(_HITL_ONE, "t_cancel_remote", "p_cancel_remote")
|
||||
rs = RunService(checkpointer=InMemorySaver())
|
||||
async with SessionLocal() as s:
|
||||
run = await rs.create_run(
|
||||
s, tenant_id="t_cancel_remote", project_id="p_cancel_remote",
|
||||
workflow_id=wf.id, input={},
|
||||
)
|
||||
run_id = run.id
|
||||
|
||||
async def _active_run():
|
||||
run_control.begin(run_id, "t_cancel_remote")
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
await run_control.end(run_id)
|
||||
|
||||
task = asyncio.create_task(_active_run())
|
||||
await asyncio.sleep(0.05)
|
||||
async with SessionLocal() as s:
|
||||
run = await s.get(Run, run_id)
|
||||
run.status = "canceled"
|
||||
await s.commit()
|
||||
|
||||
canceled = False
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
except asyncio.CancelledError:
|
||||
canceled = True
|
||||
assert canceled is True
|
||||
|
||||
|
||||
# --- (f) email HTML fallback + (g) threading ----------------------------------------------
|
||||
|
||||
|
||||
def test_email_html_only_fallback():
|
||||
p = email_ch.parse_inbound({"from": "a@b.com", "subject": "Hi",
|
||||
"html": "<p>Hello <b>world</b></p><div>Line two</div>"})
|
||||
assert "Hello world" in p["text"] and "Line two" in p["text"]
|
||||
|
||||
|
||||
def test_email_raw_html_only_fallback():
|
||||
raw = (b"From: a@b.com\r\nSubject: Hi\r\nContent-Type: text/html\r\n\r\n"
|
||||
b"<html><body><p>Body text here</p></body></html>\r\n")
|
||||
p = email_ch.parse_inbound(raw)
|
||||
assert "Body text here" in p["text"]
|
||||
|
||||
|
||||
def test_email_reply_preserves_references_and_sets_message_id():
|
||||
msg = email_ch.build_reply(to_addr="a@b.com", subject="Order", body="ok", from_addr="bot@x.com",
|
||||
in_reply_to="<m2>", references="<m0> <m1>")
|
||||
assert msg["In-Reply-To"] == "<m2>"
|
||||
assert msg["References"].split() == ["<m0>", "<m1>", "<m2>"]
|
||||
assert msg["Message-ID"] # explicit id set on the outbound reply
|
||||
# --- (i) pluggable webhook signatures -----------------------------------------------------
|
||||
|
||||
|
||||
def test_webhook_stripe_signature():
|
||||
secret = "whsec_test"
|
||||
body = b'{"id":"evt_1"}'
|
||||
ts = str(int(time.time()))
|
||||
mac = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
|
||||
req = SimpleNamespace(headers={"Stripe-Signature": f"t={ts},v1={mac}"})
|
||||
assert hooks._verify_stripe(secret, req, body, 300) is True
|
||||
# Tampered body fails.
|
||||
assert hooks._verify_stripe(secret, req, b'{"id":"evt_2"}', 300) is False
|
||||
# Stale timestamp fails the replay window.
|
||||
old = SimpleNamespace(headers={"Stripe-Signature": f"t=1,v1={mac}"})
|
||||
assert hooks._verify_stripe(secret, old, body, 300) is False
|
||||
|
||||
|
||||
def test_webhook_slack_signature():
|
||||
secret = "slack_secret"
|
||||
body = b"token=abc&team_id=T1"
|
||||
ts = str(int(time.time()))
|
||||
mac = hmac.new(secret.encode(), b"v0:" + ts.encode() + b":" + body, hashlib.sha256).hexdigest()
|
||||
req = SimpleNamespace(headers={"X-Slack-Request-Timestamp": ts, "X-Slack-Signature": f"v0={mac}"})
|
||||
assert hooks._verify_slack(secret, req, body, 300) is True
|
||||
bad = SimpleNamespace(headers={"X-Slack-Request-Timestamp": ts, "X-Slack-Signature": "v0=deadbeef"})
|
||||
assert hooks._verify_slack(secret, bad, body, 300) is False
|
||||
|
||||
|
||||
def test_webhook_default_hmac_signature():
|
||||
secret = "s3cr3t"
|
||||
body = b"payload"
|
||||
mac = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
req = SimpleNamespace(headers={"X-Hub-Signature-256": f"sha256={mac}"})
|
||||
assert hooks._verify_hmac_sha256(secret, req, body) is True
|
||||
req2 = SimpleNamespace(headers={"x-forge-signature": mac})
|
||||
assert hooks._verify_hmac_sha256(secret, req2, body) is True
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Chunking strategies: pure splitter behavior + ingest wiring (strategy resolution
|
||||
and chunk_size/overlap sourced from the project's rag_defaults)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.knowledge.splitter import chunk_text
|
||||
from forge.models import Project
|
||||
from forge.services.knowledge import KnowledgeService
|
||||
|
||||
# --- pure splitter behavior ---
|
||||
|
||||
|
||||
def test_recursive_strategy_caps_chunk_size():
|
||||
text = ("Sentence one. " * 200).strip()
|
||||
chunks = chunk_text(text, strategy="recursive", chunk_size=300, overlap=50)
|
||||
assert len(chunks) > 1
|
||||
assert all(len(c) <= 360 for c in chunks) # ~chunk_size + slack
|
||||
|
||||
|
||||
def test_section_strategy_splits_on_markdown_headers():
|
||||
doc = (
|
||||
"# Refunds\nRefunds go to the original method within 5-7 days.\n\n"
|
||||
"# Shipping\nOrders ship in 2 days.\n\n"
|
||||
"# Returns\nReturns accepted within 30 days."
|
||||
)
|
||||
chunks = chunk_text(doc, strategy="section", chunk_size=1000, overlap=100)
|
||||
assert len(chunks) == 3
|
||||
assert chunks[0].startswith("# Refunds") and "5-7 days" in chunks[0]
|
||||
assert any(c.startswith("# Shipping") for c in chunks)
|
||||
|
||||
|
||||
def test_sentence_strategy_does_not_split_on_abbreviations():
|
||||
prose = "Dr. Smith met Mr. Brown at 3 p.m. in the U.S. capital. They signed the deal."
|
||||
chunks = chunk_text(prose, strategy="sentence", chunk_size=1000, overlap=0)
|
||||
# Two real sentences fit in one chunk; abbreviations must not create extra splits.
|
||||
assert len(chunks) == 1
|
||||
assert "Dr. Smith" in chunks[0] and "U.S. capital" in chunks[0]
|
||||
|
||||
|
||||
def test_sentence_packs_to_chunk_size_with_overlap():
|
||||
prose = (
|
||||
"Alpha sentence here. Beta sentence here. Gamma sentence here. "
|
||||
"Delta sentence here. Epsilon sentence here. "
|
||||
) * 4
|
||||
chunks = chunk_text(prose, strategy="sentence", chunk_size=120, overlap=30)
|
||||
assert len(chunks) > 1
|
||||
assert all(len(c) <= 130 for c in chunks)
|
||||
|
||||
|
||||
def test_section_falls_back_to_recursive_without_headers():
|
||||
text = ("No headers here at all. " * 80).strip()
|
||||
chunks = chunk_text(text, strategy="section", chunk_size=200, overlap=40)
|
||||
assert len(chunks) > 1 # no headers -> recursive fallback still splits
|
||||
|
||||
|
||||
def test_unknown_strategy_defaults_to_recursive():
|
||||
text = ("word " * 300).strip()
|
||||
assert chunk_text(text, strategy="nonsense", chunk_size=200) == chunk_text(
|
||||
text, strategy="recursive", chunk_size=200
|
||||
)
|
||||
|
||||
|
||||
def test_empty_text_yields_no_chunks():
|
||||
assert chunk_text("", strategy="sentence") == []
|
||||
assert chunk_text(" ", strategy="section") == []
|
||||
|
||||
|
||||
# --- ingest wiring ---
|
||||
|
||||
|
||||
async def _make_project(slug: str, rag_defaults: dict) -> str:
|
||||
async with SessionLocal() as s:
|
||||
proj = Project(tenant_id="t_chunk", name="Chunk", slug=slug, config={"rag_defaults": rag_defaults})
|
||||
s.add(proj)
|
||||
await s.commit()
|
||||
await s.refresh(proj)
|
||||
return proj.id
|
||||
|
||||
|
||||
async def test_ingest_uses_project_default_strategy(tmp_path):
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma")
|
||||
pid = await _make_project("chunk-default-section", {"chunking_strategy": "section"})
|
||||
async with SessionLocal() as s:
|
||||
src = await KnowledgeService.create_source(
|
||||
s, "t_chunk", pid, kind="text", name="d", text="# A\nAlpha body.\n\n# B\nBeta body."
|
||||
)
|
||||
src = await KnowledgeService.ingest(s, src)
|
||||
assert src.status == "ready"
|
||||
assert src.chunking_strategy == "section" # inherited project default, persisted on meta
|
||||
assert src.chunks == 2 # one chunk per markdown section
|
||||
|
||||
|
||||
async def test_source_strategy_overrides_project_default(tmp_path):
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma2")
|
||||
pid = await _make_project("chunk-override", {"chunking_strategy": "section"})
|
||||
async with SessionLocal() as s:
|
||||
src = await KnowledgeService.create_source(
|
||||
s, "t_chunk", pid, kind="text", name="d2",
|
||||
text="One sentence. Two sentence. Three sentence.", chunking_strategy="sentence",
|
||||
)
|
||||
src = await KnowledgeService.ingest(s, src)
|
||||
assert src.chunking_strategy == "sentence" # per-source choice wins over project default
|
||||
|
||||
|
||||
async def test_ingest_reads_chunk_size_from_rag_defaults(tmp_path):
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma3")
|
||||
pid = await _make_project("chunk-size", {"chunk_size": 120, "chunk_overlap": 20, "chunking_strategy": "recursive"})
|
||||
long_text = ("This is a sentence about refunds and shipping policies. " * 40).strip()
|
||||
async with SessionLocal() as s:
|
||||
src = await KnowledgeService.create_source(s, "t_chunk", pid, kind="text", name="d3", text=long_text)
|
||||
src = await KnowledgeService.ingest(s, src)
|
||||
# A small project chunk_size yields many chunks (far fewer at the 1000 default).
|
||||
assert src.chunks >= 10
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Client-IP trust rule (anti-spoofing): X-Forwarded-For is believed ONLY from a configured
|
||||
reverse proxy, so a directly-connected client cannot forge its IP for per-IP rate limits or
|
||||
audit logs. Covers both call sites (deps.client_ip and the audit middleware) via the shared
|
||||
helper they now both use."""
|
||||
|
||||
from forge.util.clientip import resolve_client_ip
|
||||
|
||||
|
||||
def test_no_trusted_proxies_ignores_xff():
|
||||
# Default (direct exposure): a client-supplied X-Forwarded-For must be ignored.
|
||||
assert resolve_client_ip("203.0.113.9", "1.2.3.4", []) == "203.0.113.9"
|
||||
|
||||
|
||||
def test_trusted_proxy_uses_leftmost_xff():
|
||||
# Behind a configured proxy, the original client is the left-most XFF entry.
|
||||
assert resolve_client_ip("10.0.0.5", "1.2.3.4, 10.0.0.5", ["10.0.0.5"]) == "1.2.3.4"
|
||||
|
||||
|
||||
def test_untrusted_peer_ignores_xff():
|
||||
# A peer that is NOT a configured proxy cannot get its XFF believed.
|
||||
assert resolve_client_ip("203.0.113.9", "1.2.3.4", ["10.0.0.5"]) == "203.0.113.9"
|
||||
|
||||
|
||||
def test_wildcard_trusts_any_peer():
|
||||
assert resolve_client_ip("203.0.113.9", "1.2.3.4", ["*"]) == "1.2.3.4"
|
||||
|
||||
|
||||
def test_no_xff_returns_peer():
|
||||
assert resolve_client_ip("203.0.113.9", None, ["*"]) == "203.0.113.9"
|
||||
|
||||
|
||||
def test_no_client_and_no_xff_is_none():
|
||||
assert resolve_client_ip(None, None, []) is None
|
||||
@@ -0,0 +1,43 @@
|
||||
"""List-of-strings settings parse leniently from env — JSON array, comma-separated, or blank —
|
||||
so a stray bracket/space (e.g. from a `${VAR:-[]}` compose interpolation) can't crash startup.
|
||||
|
||||
Regression: the api container failed to boot with `FORGE_EGRESS_ALLOW_PRIVATE_HOSTS='['`
|
||||
(pydantic-settings tried json.loads('[') -> JSONDecodeError -> SettingsError).
|
||||
"""
|
||||
|
||||
from forge.config import Settings, _as_str_list
|
||||
|
||||
|
||||
def test_blank_and_mangled_are_empty():
|
||||
for v in ("", " ", "[", "[]", None):
|
||||
assert _as_str_list(v) == []
|
||||
|
||||
|
||||
def test_json_array():
|
||||
assert _as_str_list('["localhost","127.0.0.1"]') == ["localhost", "127.0.0.1"]
|
||||
|
||||
|
||||
def test_comma_separated():
|
||||
assert _as_str_list("localhost, 127.0.0.1 , host.docker.internal") == [
|
||||
"localhost", "127.0.0.1", "host.docker.internal",
|
||||
]
|
||||
|
||||
|
||||
def test_unquoted_or_mangled_bracketed():
|
||||
assert _as_str_list("[localhost,127.0.0.1]") == ["localhost", "127.0.0.1"]
|
||||
|
||||
|
||||
def test_passthrough_existing_list():
|
||||
assert _as_str_list(["a", "b"]) == ["a", "b"]
|
||||
|
||||
|
||||
def test_settings_boots_with_the_crashing_value(monkeypatch):
|
||||
monkeypatch.setenv("FORGE_EGRESS_ALLOW_PRIVATE_HOSTS", "[") # exact value that crashed the container
|
||||
s = Settings()
|
||||
assert s.egress_allow_private_hosts == []
|
||||
|
||||
|
||||
def test_settings_parses_real_egress_list(monkeypatch):
|
||||
monkeypatch.setenv("FORGE_EGRESS_ALLOW_PRIVATE_HOSTS", "localhost,127.0.0.1")
|
||||
s = Settings()
|
||||
assert s.egress_allow_private_hosts == ["localhost", "127.0.0.1"]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Per-user connected credentials: the AuthResolver picks each end user's own stored OAuth bundle,
|
||||
so a tool acts as the authenticated user downstream without the MCP token being passed through."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from forge.auth_providers.resolver import AuthResolver
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.services.auth_providers import AuthProviderService
|
||||
|
||||
|
||||
async def _provider(tenant: str, project: str) -> str:
|
||||
async with SessionLocal() as s:
|
||||
ap = await AuthProviderService.create(
|
||||
s, tenant, project, name="portal", kind="oauth2_authorization_code",
|
||||
config={
|
||||
"per_user_context_keys": ["end_user_id"],
|
||||
"token_url": "https://example.com/token",
|
||||
"header_name": "Authorization",
|
||||
"prefix": "Bearer ",
|
||||
},
|
||||
)
|
||||
return ap.id
|
||||
|
||||
|
||||
async def test_per_user_connected_credentials_resolve_per_end_user():
|
||||
tenant, project = "t_conn", "p_conn"
|
||||
ap_id = await _provider(tenant, project)
|
||||
async with SessionLocal() as s:
|
||||
ap = await AuthProviderService.get(s, tenant, ap_id)
|
||||
await AuthProviderService.set_user_connection(
|
||||
s, tenant, project, ap, "user-A", bundle={"access_token": "tok-A", "expires_at": time.time() + 3600})
|
||||
await AuthProviderService.set_user_connection(
|
||||
s, tenant, project, ap, "user-B", bundle={"access_token": "tok-B", "expires_at": time.time() + 3600})
|
||||
|
||||
resolver = AuthResolver()
|
||||
ra = await resolver.resolve(tenant_id=tenant, project_id=project, provider_id=ap_id, context={"end_user_id": "user-A"})
|
||||
assert ra.headers["Authorization"] == "Bearer tok-A"
|
||||
rb = await resolver.resolve(tenant_id=tenant, project_id=project, provider_id=ap_id, context={"end_user_id": "user-B"})
|
||||
assert rb.headers["Authorization"] == "Bearer tok-B"
|
||||
|
||||
# a user who never connected their account cannot authenticate (no bundle -> "not connected")
|
||||
with pytest.raises(KeyError):
|
||||
await resolver.resolve(tenant_id=tenant, project_id=project, provider_id=ap_id, context={"end_user_id": "user-C"})
|
||||
|
||||
|
||||
async def test_connection_status_and_clear():
|
||||
tenant, project = "t_conn2", "p_conn2"
|
||||
ap_id = await _provider(tenant, project)
|
||||
async with SessionLocal() as s:
|
||||
ap = await AuthProviderService.get(s, tenant, ap_id)
|
||||
assert (await AuthProviderService.get_user_connection(tenant, project, ap, "u1"))["connected"] is False
|
||||
await AuthProviderService.set_user_connection(s, tenant, project, ap, "u1", bundle={"access_token": "t", "expires_at": time.time() + 3600})
|
||||
async with SessionLocal() as s:
|
||||
ap = await AuthProviderService.get(s, tenant, ap_id)
|
||||
assert (await AuthProviderService.get_user_connection(tenant, project, ap, "u1"))["connected"] is True
|
||||
await AuthProviderService.clear_user_connection(s, tenant, project, ap, "u1")
|
||||
async with SessionLocal() as s:
|
||||
ap = await AuthProviderService.get(s, tenant, ap_id)
|
||||
assert (await AuthProviderService.get_user_connection(tenant, project, ap, "u1"))["connected"] is False
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Conversation-centric Traces view: turns grouped by session/user, filters, capture, purge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from sqlalchemy import select
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.main import create_app
|
||||
from forge.models import Span, Trace
|
||||
from forge.services.conversations import ConversationService
|
||||
|
||||
TENANT = "t_conv"
|
||||
|
||||
|
||||
def _pid() -> str:
|
||||
"""A fresh project id per test — the suite shares one DB file across tests, so isolate
|
||||
each test's rows under its own project to keep assertions on the full list stable."""
|
||||
return f"p_{uuid.uuid4().hex[:10]}"
|
||||
|
||||
|
||||
async def _add_trace(*, project, thread_id, actor, source, status="done", user="hi", ai="hello",
|
||||
tokens=10, cost=0.001, started=None, error=None, run_id=None):
|
||||
async with SessionLocal() as s:
|
||||
t = Trace(
|
||||
tenant_id=TENANT, project_id=project, workflow_id="wf1",
|
||||
run_id=run_id or str(uuid.uuid4()), thread_id=thread_id, name="run", status=status,
|
||||
started_at=started or datetime.utcnow(), ended_at=started or datetime.utcnow(),
|
||||
latency_ms=5, total_tokens=tokens, total_cost_usd=cost,
|
||||
source=source, actor=actor, end_user_id=None,
|
||||
user_message=user, ai_response=ai, error=error,
|
||||
)
|
||||
s.add(t)
|
||||
await s.commit()
|
||||
return t.id
|
||||
|
||||
|
||||
# --- grouping + summary --------------------------------------------------------
|
||||
|
||||
async def test_turns_group_into_one_conversation_per_thread():
|
||||
pid = _pid()
|
||||
await _add_trace(project=pid, thread_id="th1", actor="Alice", source="api", user="q1", ai="a1",
|
||||
started=datetime.utcnow() - timedelta(minutes=5))
|
||||
await _add_trace(project=pid, thread_id="th1", actor="Alice", source="api", user="q2", ai="a2",
|
||||
started=datetime.utcnow() - timedelta(minutes=4))
|
||||
await _add_trace(project=pid, thread_id="th2", actor="System", source="playground", user="test", ai="ok")
|
||||
|
||||
async with SessionLocal() as s:
|
||||
convos = await ConversationService.list(s, TENANT, pid)
|
||||
by_thread = {c.thread_id: c for c in convos}
|
||||
assert set(by_thread) == {"th1", "th2"}
|
||||
assert by_thread["th1"].turns == 2 and by_thread["th1"].actor == "Alice"
|
||||
assert by_thread["th1"].total_tokens == 20
|
||||
assert by_thread["th1"].preview == "q1" # earliest turn's user message
|
||||
assert by_thread["th2"].actor == "System" and by_thread["th2"].source == "playground"
|
||||
|
||||
|
||||
async def test_pause_and_resume_of_one_run_count_as_one_turn():
|
||||
# A HITL pause writes an `interrupted` Trace and the resume writes a `done` Trace, both under
|
||||
# the SAME run_id (and same user_message, since run.input is unchanged). They must fold into
|
||||
# ONE turn, not two - matching the run-grouped transcript in the Traces UI.
|
||||
pid = _pid()
|
||||
rid = str(uuid.uuid4())
|
||||
await _add_trace(project=pid, thread_id="thHITL", actor="System", source="playground",
|
||||
status="interrupted", user="approve this", ai=None, run_id=rid,
|
||||
started=datetime.utcnow() - timedelta(minutes=2))
|
||||
await _add_trace(project=pid, thread_id="thHITL", actor="System", source="playground",
|
||||
status="done", user="approve this", ai="done!", run_id=rid,
|
||||
started=datetime.utcnow() - timedelta(minutes=1))
|
||||
async with SessionLocal() as s:
|
||||
convos = await ConversationService.list(s, TENANT, pid)
|
||||
turns = await ConversationService.turns(s, TENANT, pid, "thHITL")
|
||||
conv = next(c for c in convos if c.thread_id == "thHITL")
|
||||
assert conv.turns == 1, "pause + resume of one run is a single turn"
|
||||
assert conv.status != "error" # an interrupt is not a failure
|
||||
assert len(turns) == 2 # both raw Trace segments are still returned; the UI groups by run_id
|
||||
|
||||
|
||||
async def test_conversation_status_is_error_if_any_turn_errored():
|
||||
pid = _pid()
|
||||
await _add_trace(project=pid, thread_id="thE", actor="Bob", source="embed", status="done")
|
||||
await _add_trace(project=pid, thread_id="thE", actor="Bob", source="embed", status="error", error="boom")
|
||||
async with SessionLocal() as s:
|
||||
convos = await ConversationService.list(s, TENANT, pid)
|
||||
errs = await ConversationService.list(s, TENANT, pid, status="error")
|
||||
oks = await ConversationService.list(s, TENANT, pid, status="success")
|
||||
assert next(c for c in convos if c.thread_id == "thE").status == "error"
|
||||
assert [c.thread_id for c in errs] == ["thE"]
|
||||
assert "thE" not in [c.thread_id for c in oks]
|
||||
|
||||
|
||||
async def test_filter_by_actor_and_source():
|
||||
pid = _pid()
|
||||
await _add_trace(project=pid, thread_id="thA", actor="Alice", source="api")
|
||||
await _add_trace(project=pid, thread_id="thS", actor="System", source="playground")
|
||||
async with SessionLocal() as s:
|
||||
alice = await ConversationService.list(s, TENANT, pid, actor="Alice")
|
||||
system = await ConversationService.list(s, TENANT, pid, source="playground")
|
||||
assert [c.thread_id for c in alice] == ["thA"]
|
||||
assert [c.thread_id for c in system] == ["thS"]
|
||||
|
||||
|
||||
async def test_search_matches_any_turn_and_keeps_the_full_conversation():
|
||||
pid = _pid()
|
||||
await _add_trace(project=pid, thread_id="match-user", actor="Alice", source="api",
|
||||
user="Find the quarterly invoice", ai="Here it is",
|
||||
started=datetime.utcnow() - timedelta(minutes=3))
|
||||
await _add_trace(project=pid, thread_id="match-user", actor="Alice", source="api",
|
||||
user="Thanks", ai="You're welcome",
|
||||
started=datetime.utcnow() - timedelta(minutes=2))
|
||||
await _add_trace(project=pid, thread_id="match-ai", actor="Bob", source="playground",
|
||||
user="What was the result?", ai="The needle is in this answer")
|
||||
await _add_trace(project=pid, thread_id="miss", actor="Carol", source="api",
|
||||
user="Unrelated", ai="Nothing to see")
|
||||
|
||||
async with SessionLocal() as s:
|
||||
user_match = await ConversationService.list(s, TENANT, pid, search="QUARTERLY")
|
||||
ai_match = await ConversationService.list(s, TENANT, pid, search="needle")
|
||||
|
||||
assert [c.thread_id for c in user_match] == ["match-user"]
|
||||
assert user_match[0].turns == 2
|
||||
assert user_match[0].total_tokens == 20
|
||||
assert [c.thread_id for c in ai_match] == ["match-ai"]
|
||||
|
||||
|
||||
async def test_turns_endpoint_returns_transcript_in_order():
|
||||
pid = _pid()
|
||||
await _add_trace(project=pid, thread_id="thT", actor="Al", source="api", user="first", ai="r1",
|
||||
started=datetime.utcnow() - timedelta(minutes=2))
|
||||
await _add_trace(project=pid, thread_id="thT", actor="Al", source="api", user="second", ai="r2",
|
||||
started=datetime.utcnow() - timedelta(minutes=1))
|
||||
async with SessionLocal() as s:
|
||||
turns = await ConversationService.turns(s, TENANT, pid, "thT")
|
||||
assert [t.user_message for t in turns] == ["first", "second"]
|
||||
assert [t.ai_response for t in turns] == ["r1", "r2"]
|
||||
|
||||
|
||||
async def test_facets_lists_distinct_actors_and_sources():
|
||||
pid = _pid()
|
||||
await _add_trace(project=pid, thread_id="f1", actor="Alice", source="api")
|
||||
await _add_trace(project=pid, thread_id="f2", actor="System", source="playground")
|
||||
async with SessionLocal() as s:
|
||||
facets = await ConversationService.facets(s, TENANT, pid)
|
||||
assert "Alice" in facets["actors"] and "System" in facets["actors"]
|
||||
assert "api" in facets["sources"] and "playground" in facets["sources"]
|
||||
|
||||
|
||||
async def test_purge_deletes_old_traces_and_spans():
|
||||
pid = _pid()
|
||||
old_id = await _add_trace(project=pid, thread_id="old", actor="X", source="api",
|
||||
started=datetime.utcnow() - timedelta(days=40))
|
||||
await _add_trace(project=pid, thread_id="new", actor="X", source="api", started=datetime.utcnow())
|
||||
async with SessionLocal() as s:
|
||||
s.add(Span(tenant_id=TENANT, trace_id=old_id, name="tool", kind="tool"))
|
||||
await s.commit()
|
||||
|
||||
async with SessionLocal() as s:
|
||||
removed = await ConversationService.purge_older_than(s, TENANT, pid, days=30)
|
||||
assert removed == 1
|
||||
async with SessionLocal() as s:
|
||||
convos = await ConversationService.list(s, TENANT, pid)
|
||||
remaining_spans = (await s.execute(select(Span).where(Span.trace_id == old_id))).scalars().all()
|
||||
assert [c.thread_id for c in convos] == ["new"]
|
||||
assert remaining_spans == [] # the old trace's spans were purged too
|
||||
|
||||
|
||||
# --- end-to-end capture through a real run ------------------------------------
|
||||
|
||||
_WF = {
|
||||
"id": "wf_conv", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "agent",
|
||||
"nodes": [
|
||||
{"id": "agent", "type": "agent", "config": {"flavor": "agent", "model": "fake:Hi there!", "tools": []}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "agent", "target": "end"}],
|
||||
}
|
||||
|
||||
|
||||
def _client() -> httpx.AsyncClient:
|
||||
app = create_app()
|
||||
app.state.checkpointer = InMemorySaver()
|
||||
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test")
|
||||
|
||||
|
||||
async def test_run_captures_source_and_transcript_end_to_end():
|
||||
"""A run through the project /run endpoint (source='api') must land as a conversation with
|
||||
the user message + AI response captured, so the Traces view can show it."""
|
||||
async with _client() as c:
|
||||
reg = (await c.post("/v1/auth/register", json={"email": f"u{uuid.uuid4().hex[:8]}@x.com", "password": "supersecret1"})).json()
|
||||
h = {"Authorization": f"Bearer {reg['access_token']}"}
|
||||
pid = (await c.post("/v1/projects", json={"name": "Conv"}, headers=h)).json()["id"]
|
||||
wid = (await c.post(f"/v1/projects/{pid}/workflows", json={"name": "Chat", "executable": _WF}, headers=h)).json()["id"]
|
||||
await c.patch(f"/v1/projects/{pid}", json={"config": {"api_workflow_id": wid}}, headers=h)
|
||||
|
||||
r = await c.post(f"/v1/projects/{pid}/run",
|
||||
json={"input": {"messages": [{"role": "user", "content": "what is forge"}]}, "stream": False},
|
||||
headers=h)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
convos = (await c.get(f"/v1/projects/{pid}/conversations", headers=h)).json()
|
||||
assert len(convos) == 1, convos
|
||||
conv = convos[0]
|
||||
assert conv["source"] == "api"
|
||||
assert conv["actor"] == "Unknown user" # /run with no end_user identity
|
||||
assert conv["preview"] == "what is forge"
|
||||
|
||||
detail = (await c.get(f"/v1/projects/{pid}/conversations/{conv['thread_id']}", headers=h)).json()
|
||||
turn = detail["turns"][0]
|
||||
assert turn["user_message"] == "what is forge"
|
||||
assert "Hi there!" in (turn["ai_response"] or "")
|
||||
# the AI-response click drills into the existing span waterfall by trace id
|
||||
assert turn["trace_id"]
|
||||
spans = (await c.get(f"/v1/projects/{pid}/traces/{turn['trace_id']}", headers=h)).json()
|
||||
assert "spans" in spans
|
||||
|
||||
# The Traces "Run again" action must replay only through the original workflow.
|
||||
other_wid = (await c.post(
|
||||
f"/v1/projects/{pid}/workflows", json={"name": "Other", "executable": _WF}, headers=h,
|
||||
)).json()["id"]
|
||||
cross_workflow = await c.post(
|
||||
f"/v1/projects/{pid}/workflows/{other_wid}/runs/{turn['run_id']}/rerun", headers=h,
|
||||
)
|
||||
assert cross_workflow.status_code == 404
|
||||
|
||||
replay = await c.post(
|
||||
f"/v1/projects/{pid}/workflows/{wid}/runs/{turn['run_id']}/rerun", headers=h,
|
||||
)
|
||||
assert replay.status_code == 201, replay.text
|
||||
replayed = replay.json()
|
||||
assert replayed["id"] != turn["run_id"]
|
||||
assert replayed["thread_id"] != conv["thread_id"]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Cost levers: accurate token counting + cheap-model defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.engine.models import cheap_model_for_credentials, default_model_for_credentials
|
||||
from forge.tools.projection import count_tokens, estimate_tokens
|
||||
|
||||
|
||||
def test_count_tokens_nonzero_and_aliased():
|
||||
n = count_tokens("hello world, this is a token counting test")
|
||||
assert n > 0
|
||||
assert estimate_tokens("hello world, this is a token counting test") == n # alias
|
||||
|
||||
|
||||
def test_count_tokens_handles_objects_and_none():
|
||||
assert count_tokens(None) == 0
|
||||
assert count_tokens({"a": [1, 2, 3], "b": "text"}) > 0
|
||||
|
||||
|
||||
def test_cheap_model_prefers_provider_nano_tier():
|
||||
assert cheap_model_for_credentials({"openai": "k"}) == "openai:gpt-4.1-nano"
|
||||
assert cheap_model_for_credentials({"anthropic": "k"}) == "anthropic:claude-haiku-4-5"
|
||||
assert cheap_model_for_credentials({}) is None
|
||||
# the cheap model is distinct from the default (frontier-ish) model
|
||||
assert cheap_model_for_credentials({"openai": "k"}) != default_model_for_credentials({"openai": "k"})
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Website crawl link extraction + source re-ingest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.knowledge.crawl import extract_links
|
||||
from forge.services.knowledge import KnowledgeService
|
||||
|
||||
|
||||
def test_extract_links_same_domain_only():
|
||||
html = """
|
||||
<a href="/about">About</a>
|
||||
<a href="https://acme.test/pricing">Pricing</a>
|
||||
<a href="https://other.test/x">External</a>
|
||||
<a href="/about#team">Fragment dup</a>
|
||||
<a href="mailto:hi@acme.test">Mail</a>
|
||||
"""
|
||||
links = extract_links(html, "https://acme.test/")
|
||||
assert "https://acme.test/about" in links
|
||||
assert "https://acme.test/pricing" in links
|
||||
assert "https://other.test/x" not in links # external domain excluded
|
||||
assert "mailto:hi@acme.test" not in links
|
||||
assert links.count("https://acme.test/about") == 1 # fragment deduped
|
||||
|
||||
|
||||
async def test_reingest_text_source_reembeds():
|
||||
async with SessionLocal() as s:
|
||||
src = await KnowledgeService.create_source(s, "t_re", "p_re", kind="text", name="doc",
|
||||
text="Forge supports website crawling and re-ingest now.")
|
||||
src = await KnowledgeService.ingest(s, src)
|
||||
assert src.status == "ready" and src.chunks >= 1
|
||||
src2 = await KnowledgeService.reingest(s, src)
|
||||
assert src2.status == "ready" and src2.chunks >= 1
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Project-level default middleware (the Guardrails & Egress screen's `config.default_middleware`)
|
||||
is prepended to EVERY agent's stack, ahead of the agent's own middleware. This locks the
|
||||
"one enforcement point on every agent" guarantee the settings UI relies on."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langchain.agents.middleware import PIIMiddleware
|
||||
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.nodes.agent_node import _common_kwargs
|
||||
|
||||
# A PII guardrail entry exactly as the Guardrails settings screen compiles it.
|
||||
_PII_DEFAULT = {
|
||||
"type": "pii",
|
||||
"config": {"_managed": True, "pii_type": "email", "strategy": "redact",
|
||||
"apply_to_input": True, "apply_to_output": True},
|
||||
}
|
||||
|
||||
|
||||
def _ctx(**kw) -> CompileContext:
|
||||
# `fake` model keeps resolve_model offline (no provider key / network).
|
||||
return CompileContext(tenant_id="t", project_id="p", default_model="fake", **kw)
|
||||
|
||||
|
||||
def test_project_default_middleware_injected_when_agent_has_none():
|
||||
ctx = _ctx(project_default_mw=[_PII_DEFAULT])
|
||||
common = _common_kwargs({"model": "fake"}, ctx)
|
||||
assert any(isinstance(m, PIIMiddleware) for m in common["middleware"]), "project default PII guardrail must reach an agent with no middleware of its own"
|
||||
|
||||
|
||||
def test_project_default_middleware_prepended_before_agent_middleware():
|
||||
ctx = _ctx(project_default_mw=[_PII_DEFAULT])
|
||||
config = {"model": "fake", "middleware": [{"type": "guardrail_regex", "config": {"patterns": ["secret"]}}]}
|
||||
built = _common_kwargs(config, ctx)["middleware"]
|
||||
names = [type(m).__name__ for m in built]
|
||||
# Default runs first (outermost); the agent's own middleware follows.
|
||||
assert names[0] == "PIIMiddleware"
|
||||
assert "_GuardrailRegexMiddleware" in names
|
||||
|
||||
|
||||
def test_no_default_middleware_is_a_no_op():
|
||||
# Empty policy adds nothing — an agent with no middleware compiles to an empty stack
|
||||
# (so leaving the guardrails screen untouched has zero runtime effect / cost).
|
||||
built = _common_kwargs({"model": "fake"}, _ctx(project_default_mw=[]))["middleware"]
|
||||
assert built == []
|
||||
|
||||
|
||||
def test_custom_pattern_pii_guardrail_compiles():
|
||||
# A "Custom pattern" row compiles to a `pii` entry with a regex `detector` and a custom
|
||||
# `pii_type` label — the shape the Guardrails screen emits for e.g. phone / national-ID.
|
||||
ctx = _ctx(project_default_mw=[{
|
||||
"type": "pii",
|
||||
"config": {"_managed": True, "pii_type": "phone", "detector": r"\d{3}-\d{3}-\d{4}", "strategy": "redact"},
|
||||
}])
|
||||
built = _common_kwargs({"model": "fake"}, ctx)["middleware"]
|
||||
m = next((x for x in built if isinstance(x, PIIMiddleware)), None)
|
||||
assert m is not None and m.pii_type == "phone"
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Regressions found by the full end-to-end application test (don't let them come back)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import Trigger
|
||||
from forge.services.tools import ToolService
|
||||
from forge.services.validation import validate_workflow
|
||||
from forge.services.workflows import WorkflowService
|
||||
|
||||
|
||||
def test_trigger_and_flow_nodes_validate():
|
||||
"""node_schema_ref must resolve trigger/flow schemas whose file name != node type
|
||||
(e.g. webhook_in -> forge/nodes/trigger_webhook). Previously: Unresolvable schema."""
|
||||
wf = {
|
||||
"id": "w", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "hook",
|
||||
"nodes": [
|
||||
{"id": "hook", "type": "webhook_in", "config": {"message_path": "text"}},
|
||||
{"id": "agent", "type": "agent", "config": {"flavor": "agent", "model": "fake:hi"}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "hook", "target": "agent"}, {"source": "agent", "target": "end"}],
|
||||
}
|
||||
res = validate_workflow(wf)
|
||||
assert res.valid, res.errors
|
||||
|
||||
|
||||
async def test_tool_test_supports_code_and_sql(tmp_path, monkeypatch):
|
||||
import sqlite3
|
||||
|
||||
from forge.config import settings
|
||||
# Code tools are OFF by default (unsandboxed RestrictedPython is not an isolation
|
||||
# boundary - audit S5); this test exercises the feature, so opt in explicitly.
|
||||
monkeypatch.setattr(settings, "enable_code_tools", True)
|
||||
code = {"name": "u", "kind": "code", "description": "upper", "language": "python",
|
||||
"source": "def main(s):\n return s.upper()", "args_schema": {"properties": {"s": {"type": "string"}}, "required": ["s"]}}
|
||||
r = await ToolService.test("t", "p", code, {"s": "hi"})
|
||||
assert r["ok"] and r["projected"] == "HI"
|
||||
|
||||
db = tmp_path / "t.db"
|
||||
con = sqlite3.connect(db)
|
||||
con.executescript("CREATE TABLE t(id int, name text); INSERT INTO t VALUES (1,'x');")
|
||||
con.commit()
|
||||
con.close()
|
||||
sql = {"name": "q", "kind": "sql", "description": "q", "connection_url": f"sqlite+aiosqlite:///{db.as_posix()}",
|
||||
"query": "SELECT name FROM t WHERE id = :id", "args_schema": {"properties": {"id": {"type": "integer"}}}}
|
||||
r = await ToolService.test("t", "p", sql, {"id": 1})
|
||||
assert r["ok"] and r["projected"] == [{"name": "x"}]
|
||||
|
||||
|
||||
async def test_workflow_create_syncs_triggers():
|
||||
"""Creating a workflow whose executable has a webhook_in must register a Trigger row."""
|
||||
ex = {
|
||||
"id": "w2", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "hook",
|
||||
"nodes": [
|
||||
{"id": "hook", "type": "webhook_in", "config": {}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "hook", "target": "end"}],
|
||||
}
|
||||
async with SessionLocal() as s:
|
||||
wf = await WorkflowService.create(s, "t_sync", "p_sync", name="Hooked", executable=ex)
|
||||
rows = (await s.execute(Trigger.__table__.select().where(Trigger.workflow_id == wf.id))).fetchall()
|
||||
assert len(rows) == 1 and rows[0].kind == "webhook_in" and rows[0].key
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Embedding spans (item 9): embed calls run through the Embedder are timed + priced as
|
||||
`kind="embedding"` spans on the active run tracer, so RAG/memory embedding latency and cost
|
||||
show up in traces. Wrapping at the Embedder level covers every call site (knowledge/*,
|
||||
nodes/rag.py, services/memory.py) at once.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.knowledge.embeddings import _est_tokens, resolve_embedder
|
||||
from forge.tracing.tracer import ForgeTracer, embedding_span
|
||||
|
||||
|
||||
def test_est_tokens():
|
||||
assert _est_tokens(["aaaa"]) == 1 # 4 chars / 4
|
||||
assert _est_tokens(["", None]) == 0 # handles empties
|
||||
assert _est_tokens(["a" * 40, "b" * 40]) == 20
|
||||
|
||||
|
||||
def test_embedding_span_noop_off_run():
|
||||
# No active tracer bound -> the context manager is a harmless no-op.
|
||||
with embedding_span("some:model", n_texts=3):
|
||||
pass
|
||||
|
||||
|
||||
async def test_embed_call_records_embedding_span():
|
||||
tr = ForgeTracer() # __init__ binds this as the active tracer for this async context
|
||||
embedder = resolve_embedder(None) # default local fastembed
|
||||
await embedder.aembed_query("hello world")
|
||||
spans = [s for s in tr.ordered() if s.kind == "embedding"]
|
||||
assert spans, "an embed call should record an embedding span on the active tracer"
|
||||
assert spans[0].model == embedder.name
|
||||
assert spans[0].attributes.get("n_texts") == 1
|
||||
assert spans[0].end is not None and spans[0].end >= spans[0].start # latency captured
|
||||
@@ -0,0 +1,197 @@
|
||||
"""End-to-end validation of the Forge engine, fully offline (fake model).
|
||||
|
||||
Proves: state TypedDict + reducers, the node registry, the workflow compiler,
|
||||
router expression routing, middleware attachment, and an actual graph run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.graph import START, StateGraph
|
||||
from langgraph.types import Command
|
||||
|
||||
from forge.engine.compiler import compile_workflow
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.engine.expressions import ExpressionError, eval_expression
|
||||
from forge.engine.state import build_state_typeddict
|
||||
from forge.tools.projection import estimate_tokens, project_response
|
||||
|
||||
|
||||
def _ctx() -> CompileContext:
|
||||
return CompileContext(tenant_id="t1", project_id="p1", checkpointer=InMemorySaver())
|
||||
|
||||
|
||||
def _wf() -> dict:
|
||||
return {
|
||||
"id": "wf_test",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"messages": {"type": "list[message]", "reducer": "add_messages"},
|
||||
"intent": {"type": "str", "reducer": "last"},
|
||||
},
|
||||
"entry_node": "start",
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "config": {}},
|
||||
{
|
||||
"id": "route",
|
||||
"type": "router",
|
||||
"config": {
|
||||
"expression": "intent",
|
||||
"cases": {"billing": "billing_agent", "tech": "tech_agent"},
|
||||
"default": "billing_agent",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "billing_agent",
|
||||
"type": "agent",
|
||||
"config": {
|
||||
"flavor": "agent",
|
||||
"model": "fake:Billing handled.",
|
||||
"system_prompt": "You are the billing agent.",
|
||||
"middleware": [
|
||||
{"type": "model_call_limit", "config": {"run_limit": 3}},
|
||||
{"type": "summarization", "config": {"trigger": ["tokens", 4000]}},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "tech_agent",
|
||||
"type": "agent",
|
||||
"config": {"flavor": "agent", "model": "fake:Tech handled."},
|
||||
},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"source": "start", "target": "route"},
|
||||
{"source": "billing_agent", "target": "end"},
|
||||
{"source": "tech_agent", "target": "end"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# --- state builder --------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_state_typeddict_injects_messages_and_reducers():
|
||||
State = build_state_typeddict({"findings": {"type": "list[str]", "reducer": "add"}})
|
||||
ann = State.__annotations__
|
||||
assert "messages" in ann # auto-injected
|
||||
assert "findings" in ann
|
||||
|
||||
|
||||
async def test_add_reducer_accumulates_across_nodes():
|
||||
State = build_state_typeddict(
|
||||
{"items": {"type": "list[str]", "reducer": "add"}, "intent": {"type": "str", "reducer": "last"}}
|
||||
)
|
||||
g = StateGraph(State)
|
||||
g.add_node("a", lambda s: {"items": ["a"], "intent": "x"})
|
||||
g.add_node("b", lambda s: {"items": ["b"], "intent": "y"})
|
||||
g.add_edge(START, "a")
|
||||
g.add_edge("a", "b")
|
||||
g.set_finish_point("b")
|
||||
out = await g.compile().ainvoke({})
|
||||
assert out["items"] == ["a", "b"] # accumulated via operator.add
|
||||
assert out["intent"] == "y" # overwritten via "last"
|
||||
|
||||
|
||||
# --- expressions ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_expression_sandbox_evaluates_and_blocks_imports():
|
||||
assert eval_expression("intent == 'billing'", {"intent": "billing"}) is True
|
||||
assert eval_expression("len(messages) > 1", {"messages": [1, 2, 3]}) is True
|
||||
with pytest.raises(ExpressionError):
|
||||
eval_expression("__import__('os').system('echo hi')", {})
|
||||
|
||||
|
||||
# --- projection (token lever) --------------------------------------------
|
||||
|
||||
|
||||
def test_projection_jmespath_then_fields_then_full():
|
||||
raw = {"data": {"totals": {"subtotal": 90, "grand_total": 99}, "line_items": [1, 2, 3, 4]}}
|
||||
jm = project_response(raw, {"projection_jmespath": "data.totals.{sub: subtotal, total: grand_total}"})
|
||||
assert jm == {"sub": 90, "total": 99}
|
||||
|
||||
fld = project_response(
|
||||
raw,
|
||||
{"fields": [
|
||||
{"path": "data.totals.subtotal", "include_in_llm": True},
|
||||
{"path": "data.line_items", "include_in_llm": False},
|
||||
]},
|
||||
)
|
||||
assert fld == {"data.totals.subtotal": 90}
|
||||
|
||||
assert project_response(raw, {}) == raw
|
||||
assert estimate_tokens(raw) > estimate_tokens(jm) # the meter shrinks
|
||||
|
||||
|
||||
# --- compile + run --------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"intent,expected",
|
||||
[("billing", "Billing handled."), ("tech", "Tech handled."), ("other", "Billing handled.")],
|
||||
)
|
||||
async def test_compile_and_run_routes_correctly(intent, expected):
|
||||
graph = compile_workflow(_wf(), _ctx())
|
||||
config = {"configurable": {"thread_id": f"thread-{intent}"}}
|
||||
out = await graph.ainvoke(
|
||||
{"messages": [HumanMessage(content="hi")], "intent": intent}, config
|
||||
)
|
||||
last = out["messages"][-1]
|
||||
assert isinstance(last, AIMessage)
|
||||
assert last.content == expected
|
||||
|
||||
|
||||
async def test_human_input_resume_can_drive_router_branch():
|
||||
wf = {
|
||||
"id": "wf_hitl_router",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"messages": {"type": "list[message]", "reducer": "add_messages"},
|
||||
"decision": {"type": "str", "reducer": "last"},
|
||||
"result": {"type": "str", "reducer": "last"},
|
||||
},
|
||||
"entry_node": "start",
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "config": {}},
|
||||
{
|
||||
"id": "review",
|
||||
"type": "human_input",
|
||||
"config": {
|
||||
"prompt": "Approve?",
|
||||
"allowed_decisions": ["approve", "reject"],
|
||||
"output_key": "decision",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "route",
|
||||
"type": "router",
|
||||
"config": {
|
||||
"expression": "decision",
|
||||
"cases": {"approve": "approved", "reject": "rejected"},
|
||||
"default": "rejected",
|
||||
},
|
||||
},
|
||||
{"id": "approved", "type": "transform", "config": {"expression": "'approved'", "output_key": "result"}},
|
||||
{"id": "rejected", "type": "transform", "config": {"expression": "'rejected'", "output_key": "result"}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"source": "start", "target": "review"},
|
||||
{"source": "review", "target": "route"},
|
||||
{"source": "approved", "target": "end"},
|
||||
{"source": "rejected", "target": "end"},
|
||||
],
|
||||
}
|
||||
graph = compile_workflow(wf, _ctx())
|
||||
config = {"configurable": {"thread_id": "hitl-router"}}
|
||||
|
||||
first = await graph.ainvoke({"messages": [HumanMessage(content="needs review")]}, config)
|
||||
assert "__interrupt__" in first
|
||||
|
||||
out = await graph.ainvoke(Command(resume="approve"), config)
|
||||
assert out["decision"] == "approve"
|
||||
assert out["result"] == "approved"
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Engine + knowledge regression tests:
|
||||
multi-label classifier + parallel router, knowledge_search builtin, KB folders,
|
||||
run-thread reuse, retry_on mapping, guardrail replacement, validation warnings,
|
||||
and the embedder cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.engine.compiler import compile_workflow
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.engine.middleware_compiler import _retry_exceptions
|
||||
from forge.services.validation import validate_workflow
|
||||
|
||||
|
||||
def _ctx() -> CompileContext:
|
||||
return CompileContext(tenant_id="t1", project_id="p1", checkpointer=InMemorySaver())
|
||||
|
||||
|
||||
def _cfg(thread: str) -> dict:
|
||||
return {"configurable": {"thread_id": thread}}
|
||||
|
||||
|
||||
# ---------- multi-label classifier + parallel (multi) router ----------
|
||||
|
||||
def _multi_wf() -> dict:
|
||||
return {
|
||||
"id": "wf_multi",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"messages": {"type": "list[message]", "reducer": "add_messages"},
|
||||
"intents": {"type": "list[str]", "reducer": "last"},
|
||||
},
|
||||
"entry_node": "start",
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "config": {}},
|
||||
{
|
||||
"id": "classify",
|
||||
"type": "classifier",
|
||||
# fake model can't do structured output -> keyword fallback collects
|
||||
# EVERY matching label (multi_label).
|
||||
"config": {"labels": ["weather", "billing"], "output_key": "intents",
|
||||
"multi_label": True, "model": "fake:n/a"},
|
||||
},
|
||||
{
|
||||
"id": "route",
|
||||
"type": "router",
|
||||
"config": {"expression": "intents", "multi": True,
|
||||
"cases": {"weather": "weather_agent", "billing": "billing_agent"},
|
||||
"default": "general_agent"},
|
||||
},
|
||||
{"id": "weather_agent", "type": "agent",
|
||||
"config": {"flavor": "agent", "model": "fake:WEATHER-ANSWER"}},
|
||||
{"id": "billing_agent", "type": "agent",
|
||||
"config": {"flavor": "agent", "model": "fake:BILLING-ANSWER"}},
|
||||
{"id": "general_agent", "type": "agent",
|
||||
"config": {"flavor": "agent", "model": "fake:GENERAL-ANSWER"}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"source": "start", "target": "classify"},
|
||||
{"source": "classify", "target": "route"},
|
||||
{"source": "weather_agent", "target": "end"},
|
||||
{"source": "billing_agent", "target": "end"},
|
||||
{"source": "general_agent", "target": "end"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_multi_label_classifier_fallback_writes_list():
|
||||
graph = compile_workflow(_multi_wf(), _ctx())
|
||||
out = await graph.ainvoke(
|
||||
{"messages": [HumanMessage(content="What's the weather like, and a question about my billing?")]},
|
||||
_cfg("multi-1"),
|
||||
)
|
||||
assert sorted(out["intents"]) == ["billing", "weather"]
|
||||
|
||||
|
||||
async def test_multi_router_fans_out_to_all_matching_cases():
|
||||
graph = compile_workflow(_multi_wf(), _ctx())
|
||||
out = await graph.ainvoke(
|
||||
{"messages": [HumanMessage(content="weather and billing please")]},
|
||||
_cfg("multi-2"),
|
||||
)
|
||||
texts = [getattr(m, "content", "") for m in out["messages"]]
|
||||
assert any("WEATHER-ANSWER" in t for t in texts)
|
||||
assert any("BILLING-ANSWER" in t for t in texts)
|
||||
assert not any("GENERAL-ANSWER" in t for t in texts)
|
||||
|
||||
|
||||
async def test_multi_router_falls_back_to_default_when_no_match():
|
||||
graph = compile_workflow(_multi_wf(), _ctx())
|
||||
out = await graph.ainvoke(
|
||||
{"messages": [HumanMessage(content="hello there, completely unrelated")]},
|
||||
_cfg("multi-3"),
|
||||
)
|
||||
texts = [getattr(m, "content", "") for m in out["messages"]]
|
||||
assert any("GENERAL-ANSWER" in t for t in texts)
|
||||
|
||||
|
||||
# ---------- validation warnings ----------
|
||||
|
||||
def test_router_without_default_warns():
|
||||
wf = _multi_wf()
|
||||
for n in wf["nodes"]:
|
||||
if n["id"] == "route":
|
||||
n["config"].pop("default")
|
||||
wf["nodes"] = [n for n in wf["nodes"] if n["id"] != "general_agent"]
|
||||
wf["edges"] = [e for e in wf["edges"] if e["source"] != "general_agent"]
|
||||
res = validate_workflow(wf)
|
||||
assert res.valid
|
||||
assert any("no Default path" in w["message"] for w in res.warnings)
|
||||
|
||||
|
||||
# ---------- tool_retry retry_on mapping ----------
|
||||
|
||||
def test_retry_exceptions_maps_names_to_types():
|
||||
excs = _retry_exceptions(["timeout", "http_error", "value_error", "bogus_name"])
|
||||
assert TimeoutError in excs
|
||||
assert httpx.HTTPError in excs
|
||||
assert ValueError in excs
|
||||
assert len(excs) == 3 # unknown names are skipped
|
||||
|
||||
|
||||
# ---------- guardrail_regex block actually replaces ----------
|
||||
|
||||
async def test_guardrail_block_replaces_reply():
|
||||
wf = {
|
||||
"id": "wf_guard", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "start",
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "config": {}},
|
||||
{"id": "agent", "type": "agent",
|
||||
"config": {"flavor": "agent", "model": "fake:the forbidden secret",
|
||||
"middleware": [{"type": "guardrail_regex",
|
||||
"config": {"patterns": ["forbidden"], "on_match": "block"}}]}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "start", "target": "agent"}, {"source": "agent", "target": "end"}],
|
||||
}
|
||||
graph = compile_workflow(wf, _ctx())
|
||||
out = await graph.ainvoke({"messages": [HumanMessage(content="hi")]}, _cfg("guard-1"))
|
||||
texts = [getattr(m, "content", "") for m in out["messages"]]
|
||||
assert any("[blocked by content guardrail]" in t for t in texts)
|
||||
assert not any("forbidden secret" in t for t in texts)
|
||||
|
||||
|
||||
# ---------- knowledge_search builtin ----------
|
||||
|
||||
async def test_knowledge_search_builtin_reports_empty_kb():
|
||||
from forge.tools.materialize import materialize_tool
|
||||
|
||||
tool = materialize_tool({"kind": "builtin", "builtin": "knowledge_search", "name": "kb_search"}, _ctx())
|
||||
out = await tool.ainvoke({"query": "anything at all"})
|
||||
assert "No relevant knowledge" in out
|
||||
|
||||
|
||||
# ---------- KB folders ----------
|
||||
|
||||
async def test_source_folders_scope_search_and_listing():
|
||||
from forge.services.knowledge import KnowledgeService
|
||||
|
||||
async with SessionLocal() as s:
|
||||
a = await KnowledgeService.create_source(
|
||||
s, "t_fold", "p_fold", kind="text", name="manual",
|
||||
text="The frobnicator manual explains frobnication in detail.", folder="Manuals")
|
||||
await KnowledgeService.ingest(s, a)
|
||||
b = await KnowledgeService.create_source(
|
||||
s, "t_fold", "p_fold", kind="text", name="policy",
|
||||
text="The vacation policy covers holidays and leave days.", folder="Policies")
|
||||
await KnowledgeService.ingest(s, b)
|
||||
|
||||
folders = await KnowledgeService.list_folders(s, "t_fold", "p_fold")
|
||||
assert folders == ["Manuals", "Policies"]
|
||||
|
||||
hits = await KnowledgeService.search(s, "t_fold", "p_fold", "frobnication manual", top_k=4, folders=["Manuals"])
|
||||
assert hits and all(h.metadata.get("source_id") == a.id for h in hits)
|
||||
|
||||
none = await KnowledgeService.search(s, "t_fold", "p_fold", "frobnication", top_k=4, folders=["DoesNotExist"])
|
||||
assert none == []
|
||||
|
||||
|
||||
# ---------- run thread reuse ----------
|
||||
|
||||
async def test_create_run_reuses_thread():
|
||||
from forge.services.runs import RunService
|
||||
from forge.services.workflows import WorkflowService
|
||||
|
||||
wf_def = {
|
||||
"id": "wf", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "start",
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "config": {}},
|
||||
{"id": "agent", "type": "agent", "config": {"flavor": "agent", "model": "fake:ok"}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "start", "target": "agent"}, {"source": "agent", "target": "end"}],
|
||||
}
|
||||
svc = RunService(checkpointer=InMemorySaver())
|
||||
async with SessionLocal() as s:
|
||||
wf = await WorkflowService.create(s, "t_thr", "p_thr", name="thread-reuse")
|
||||
await WorkflowService.save_canvas(s, wf, {}, wf_def)
|
||||
run1 = await svc.create_run(s, tenant_id="t_thr", project_id="p_thr", workflow_id=wf.id,
|
||||
input={"messages": [{"role": "user", "content": "one"}]})
|
||||
run2 = await svc.create_run(s, tenant_id="t_thr", project_id="p_thr", workflow_id=wf.id,
|
||||
input={"messages": [{"role": "user", "content": "two"}]},
|
||||
thread_id=run1.thread_id)
|
||||
run3 = await svc.create_run(s, tenant_id="t_thr", project_id="p_thr", workflow_id=wf.id,
|
||||
input={"messages": [{"role": "user", "content": "three"}]})
|
||||
assert run2.thread_id == run1.thread_id
|
||||
assert run3.thread_id != run1.thread_id
|
||||
|
||||
# A caller may echo back the composite LangGraph id (`{tenant}:{uuid}`) instead of the DB
|
||||
# Thread.id - create_run must resolve either to the SAME thread, else memory is not shared.
|
||||
from sqlalchemy import select
|
||||
|
||||
from forge.models import Thread
|
||||
|
||||
async with SessionLocal() as s:
|
||||
lg_id = (await s.execute(select(Thread.lg_thread_id).where(Thread.id == run1.thread_id))).scalar_one()
|
||||
run4 = await svc.create_run(s, tenant_id="t_thr", project_id="p_thr", workflow_id=wf.id,
|
||||
input={"messages": [{"role": "user", "content": "four"}]},
|
||||
thread_id=lg_id)
|
||||
assert run4.thread_id == run1.thread_id
|
||||
|
||||
|
||||
# ---------- embedder cache ----------
|
||||
|
||||
def test_embedder_cache_returns_same_instance_and_right_dims():
|
||||
from forge.knowledge.embeddings import resolve_embedder
|
||||
|
||||
a = resolve_embedder("openai:text-embedding-3-small", "sk-test-cache")
|
||||
b = resolve_embedder("openai:text-embedding-3-small", "sk-test-cache")
|
||||
other_key = resolve_embedder("openai:text-embedding-3-small", "sk-different")
|
||||
large = resolve_embedder("openai:text-embedding-3-large", "sk-test-cache")
|
||||
assert a is b
|
||||
assert other_key is not a
|
||||
assert a.dim == 1536
|
||||
assert large.dim == 3072
|
||||
assert large.name == "text-embedding-3-large"
|
||||
|
||||
|
||||
# ---------- qa kinds multi-filter ----------
|
||||
|
||||
async def test_lookup_kinds_list_filters_multiple_categories():
|
||||
from forge.services.knowledge import KnowledgeService
|
||||
|
||||
async with SessionLocal() as s:
|
||||
await KnowledgeService.create_qa(s, "t_mk", "p_mk", question="alpha question", answer="a", kind="billing")
|
||||
await KnowledgeService.create_qa(s, "t_mk", "p_mk", question="beta question", answer="b", kind="shipping")
|
||||
await KnowledgeService.create_qa(s, "t_mk", "p_mk", question="gamma question", answer="c", kind="faq")
|
||||
hit = await KnowledgeService.lookup(s, "t_mk", "p_mk", "alpha question", threshold=0.8, kinds=["billing", "shipping"])
|
||||
assert hit and hit["kind"] == "billing"
|
||||
miss = await KnowledgeService.lookup(s, "t_mk", "p_mk", "alpha question", threshold=0.8, kinds=["faq"])
|
||||
assert miss is None
|
||||
# empty kinds list = all kinds (no filter)
|
||||
any_hit = await KnowledgeService.lookup(s, "t_mk", "p_mk", "gamma question", threshold=0.8, kinds=[])
|
||||
assert any_hit and any_hit["kind"] == "faq"
|
||||
|
||||
|
||||
# Report-row grouping (workflow / assistant / other / deleted-workflow) is now covered
|
||||
# end-to-end against the SQL aggregate path in tests/test_stats.py.
|
||||
|
||||
|
||||
# ---------- qa custom kinds ----------
|
||||
|
||||
async def test_qa_custom_kind_roundtrip_and_lookup_filter():
|
||||
from forge.services.knowledge import KnowledgeService
|
||||
|
||||
async with SessionLocal() as s:
|
||||
await KnowledgeService.create_qa(s, "t_kind", "p_kind", question="How do I reset the frobnicator?",
|
||||
answer="Hold the red button for 5 seconds.", kind="troubleshooting")
|
||||
await KnowledgeService.create_qa(s, "t_kind", "p_kind", question="What are your business hours?",
|
||||
answer="9 to 5 on weekdays.", kind="faq")
|
||||
hit = await KnowledgeService.lookup(s, "t_kind", "p_kind", "How do I reset the frobnicator?",
|
||||
threshold=0.8, kind="troubleshooting")
|
||||
assert hit and hit["kind"] == "troubleshooting"
|
||||
miss = await KnowledgeService.lookup(s, "t_kind", "p_kind", "How do I reset the frobnicator?",
|
||||
threshold=0.8, kind="faq")
|
||||
assert miss is None
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Per-environment endpoint substitution ({{env.*}} from FORGE_TOOL_VARS / settings.tool_vars).
|
||||
|
||||
A tool/auth endpoint template references {{env.<key>}}; the value is supplied per environment via
|
||||
the tool_vars setting, so the SAME tool DB row resolves to a different real host in dev/qa/prod.
|
||||
Unlike {{ctx.*}} (lenient - a missing value is dropped/empty), a missing {{env.*}} key FAILS the
|
||||
call loudly (MissingTemplateVar), so a misconfigured environment never sends a broken URL.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import forge.tools.graphql as gql_mod
|
||||
import forge.tools.rest as rest_mod
|
||||
from forge.auth_providers.templates import MissingTemplateVar
|
||||
from forge.tools.graphql import execute_graphql
|
||||
from forge.tools.rest import execute_rest
|
||||
|
||||
|
||||
def _capturing_client(sink: dict) -> httpx.AsyncClient:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
sink["request"] = request
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
async def test_env_substitution_in_url(monkeypatch):
|
||||
"""{{env.*}} in a url_template resolves from settings.tool_vars to the env's real host."""
|
||||
monkeypatch.setattr(rest_mod.settings, "tool_vars", {"api_base": "https://api.qa.example.com"})
|
||||
sink: dict = {}
|
||||
cfg = {
|
||||
"name": "orders_get",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url_template": "{{env.api_base}}/v1/orders/{id}",
|
||||
"fields": [{"path": "id", "type": "string", "in": "path", "required": True, "llm_visible": True}],
|
||||
"headers": [],
|
||||
},
|
||||
}
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(cfg, {"id": "O-1"}, tenant_id="t", project_id="p", client=client)
|
||||
assert str(sink["request"].url) == "https://api.qa.example.com/v1/orders/O-1"
|
||||
|
||||
|
||||
async def test_env_substitution_in_body_template(monkeypatch):
|
||||
"""{{env.*}} works alongside {{input.*}} in a JSON body template."""
|
||||
monkeypatch.setattr(rest_mod.settings, "tool_vars", {"tenant": "acme-qa"})
|
||||
sink: dict = {}
|
||||
cfg = {
|
||||
"name": "order_add",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url_template": "https://portal.example.dev/orders",
|
||||
"fields": [{"path": "amount", "type": "integer", "in": "body", "llm_visible": True}],
|
||||
"headers": [],
|
||||
"body_template": '{"amount": {{ input.amount }}, "tenant": "{{ env.tenant }}"}',
|
||||
},
|
||||
}
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(cfg, {"amount": 5}, tenant_id="t", project_id="p", client=client)
|
||||
assert json.loads(sink["request"].content) == {"amount": 5, "tenant": "acme-qa"}
|
||||
|
||||
|
||||
async def test_undefined_env_var_fails_loud(monkeypatch):
|
||||
"""A template referencing an env var absent from tool_vars raises (never a broken request)."""
|
||||
monkeypatch.setattr(rest_mod.settings, "tool_vars", {}) # nothing defined for this env
|
||||
sink: dict = {}
|
||||
cfg = {
|
||||
"name": "x",
|
||||
"request": {"method": "GET", "url_template": "{{env.api_base}}/x", "fields": [], "headers": []},
|
||||
}
|
||||
async with _capturing_client(sink) as client:
|
||||
with pytest.raises(MissingTemplateVar) as e:
|
||||
await execute_rest(cfg, {}, tenant_id="t", project_id="p", client=client)
|
||||
assert "api_base" in str(e.value)
|
||||
assert "request" not in sink # the call never went out
|
||||
|
||||
|
||||
async def test_ctx_stays_lenient_alongside_strict_env(monkeypatch):
|
||||
"""env is strict, but ctx keeps its lenient behavior (missing -> empty) in the same template."""
|
||||
monkeypatch.setattr(rest_mod.settings, "tool_vars", {"base": "https://api.example.com"})
|
||||
sink: dict = {}
|
||||
cfg = {
|
||||
"name": "x",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url_template": "{{env.base}}/x?tok={{ctx.absent}}",
|
||||
"fields": [],
|
||||
"headers": [],
|
||||
},
|
||||
}
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(cfg, {}, tenant_id="t", project_id="p", context={}, client=client)
|
||||
# env resolved; the missing ctx token rendered empty rather than raising.
|
||||
assert str(sink["request"].url) == "https://api.example.com/x?tok="
|
||||
|
||||
|
||||
async def test_graphql_endpoint_env_substitution(monkeypatch):
|
||||
"""The GraphQL endpoint (previously used verbatim) now resolves {{env.*}} too."""
|
||||
monkeypatch.setattr(gql_mod.settings, "tool_vars", {"gql_base": "https://gql.prod.example.com"})
|
||||
sink: dict = {}
|
||||
cfg = {"endpoint": "{{env.gql_base}}/graphql", "query": "{ ping }", "variables": []}
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_graphql(cfg, {}, tenant_id="t", project_id="p", client=client)
|
||||
assert str(sink["request"].url) == "https://gql.prod.example.com/graphql"
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Error-workflow fallback: an erroring run returns the on_error message gracefully."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import Workflow
|
||||
from forge.services.dispatch import dispatch_message
|
||||
from forge.services.runs import RunService
|
||||
|
||||
# agent with an unknown middleware type -> compile_workflow raises (deterministic, offline).
|
||||
# run_to_completion compiles inside its try block, so the on_error fallback covers it.
|
||||
_ERR_WF = {
|
||||
"id": "wf_err", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "agent",
|
||||
"on_error": {"message": "Sorry - something went wrong. A teammate will follow up."},
|
||||
"nodes": [
|
||||
{"id": "agent", "type": "agent", "config": {"flavor": "agent", "model": "fake:hi", "middleware": [{"type": "___nonexistent___", "enabled": True}]}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "agent", "target": "end"}],
|
||||
}
|
||||
|
||||
|
||||
async def test_errored_run_returns_on_error_message():
|
||||
async with SessionLocal() as s:
|
||||
wf = Workflow(tenant_id="t_err", project_id="p_err", name="Err", executable=_ERR_WF, status="active")
|
||||
s.add(wf)
|
||||
await s.commit()
|
||||
await s.refresh(wf)
|
||||
rs = RunService(checkpointer=InMemorySaver())
|
||||
result = await dispatch_message(rs, tenant_id="t_err", project_id="p_err", workflow_id=wf.id, text="hi")
|
||||
assert result.get("error") # the run did fail
|
||||
assert result.get("error_handled") is True
|
||||
assert result.get("answer") == "Sorry - something went wrong. A teammate will follow up."
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Eval harness (dataset run + scoring) and ephemeral retrieval context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langchain_core.messages import RemoveMessage, SystemMessage
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.models import Workflow
|
||||
from forge.nodes.rag import _KB_TAG, retrieval_factory
|
||||
from forge.services.evals import EvalService, _score_deterministic
|
||||
from forge.services.runs import RunService
|
||||
|
||||
# --- ephemeral retrieval ---
|
||||
|
||||
|
||||
async def test_retrieval_removes_prior_kb_message():
|
||||
ctx = CompileContext(tenant_id="t_r", project_id="p_r")
|
||||
node = retrieval_factory({"announce_empty": True, "top_k": 2}, ctx)
|
||||
prior = SystemMessage(content="old KB context", additional_kwargs={_KB_TAG: True})
|
||||
prior.id = "kb-old"
|
||||
user = {"role": "user", "content": "anything"}
|
||||
out = await node({"messages": [prior, user]})
|
||||
msgs = out.get("messages", [])
|
||||
# prior KB message is removed; a fresh tagged one is added
|
||||
assert any(isinstance(m, RemoveMessage) and m.id == "kb-old" for m in msgs)
|
||||
assert any(isinstance(m, SystemMessage) and m.additional_kwargs.get(_KB_TAG) for m in msgs)
|
||||
|
||||
|
||||
def test_score_modes():
|
||||
assert _score_deterministic("contains", "The answer is 42 friend", "42") is True
|
||||
assert _score_deterministic("exact", "42", "42") is True
|
||||
assert _score_deterministic("exact", "the answer is 42", "42") is False
|
||||
assert _score_deterministic("regex", "order #A-1007 shipped", r"#A-\d+") is True
|
||||
|
||||
|
||||
# --- eval run ---
|
||||
|
||||
_WF = {
|
||||
"id": "wf_e", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "agent",
|
||||
"nodes": [
|
||||
{"id": "agent", "type": "agent", "config": {"flavor": "agent", "model": "fake:Your order total is 42 dollars."}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "agent", "target": "end"}],
|
||||
}
|
||||
|
||||
|
||||
async def test_eval_run_scores_dataset():
|
||||
async with SessionLocal() as s:
|
||||
wf = Workflow(tenant_id="t_e", project_id="p_e", name="E", executable=_WF, status="active")
|
||||
s.add(wf)
|
||||
await s.commit()
|
||||
await s.refresh(wf)
|
||||
ds = await EvalService.create(s, "t_e", "p_e", name="smoke", workflow_id=wf.id, score_mode="contains",
|
||||
items=[{"input": "total?", "expected": "42"}, {"input": "hi", "expected": "nonexistent-string"}])
|
||||
rs = RunService(checkpointer=InMemorySaver())
|
||||
report = await EvalService.run(s, rs, ds)
|
||||
assert report["summary"]["total"] == 2
|
||||
assert report["summary"]["passed"] == 1 # first contains "42", second does not
|
||||
assert report["results"][0]["passed"] is True and report["results"][1]["passed"] is False
|
||||
assert ds.last_pass_rate == 0.5
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Hardening for the eval harness, tracer, quota, and build assistant (findings F1-F7).
|
||||
|
||||
House style: bare `async def` tests, offline `fake:` models, InMemorySaver, direct
|
||||
service calls. No provider keys / network (embedding + judge paths are exercised via
|
||||
their "unavailable" branches so the suite stays offline and fast).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from sqlalchemy import select
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.engine.models import make_fake_model
|
||||
from forge.models import Component, Dataset, McpClient, Run, Tenant, Tool, Workflow
|
||||
from forge.services.evals import (
|
||||
EvalService,
|
||||
_is_real_judge,
|
||||
_score_json,
|
||||
_score_numeric,
|
||||
)
|
||||
from forge.services.quota import QuotaExceeded, check_run_quota, usage_today
|
||||
from forge.services.runs import RunService
|
||||
|
||||
# A workflow whose (offline) agent always answers with "42 dollars" - lets us assert
|
||||
# deterministic pass/fail without any provider key.
|
||||
_WF = {
|
||||
"id": "wf_h", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "agent",
|
||||
"nodes": [
|
||||
{"id": "agent", "type": "agent", "config": {"flavor": "agent", "model": "fake:Your order total is 42 dollars."}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "agent", "target": "end"}],
|
||||
}
|
||||
|
||||
|
||||
# --- F1 + F2: bounded-concurrency run, persisted history, regression gate ---
|
||||
|
||||
|
||||
async def test_eval_run_persists_history_and_regression_gate():
|
||||
async with SessionLocal() as s:
|
||||
wf = Workflow(tenant_id="t_h", project_id="p_h", name="H", executable=_WF, status="active")
|
||||
s.add(wf)
|
||||
await s.commit()
|
||||
await s.refresh(wf)
|
||||
ds = await EvalService.create(
|
||||
s, "t_h", "p_h", name="h", workflow_id=wf.id, score_mode="contains",
|
||||
items=[{"input": "a", "expected": "42"}, {"input": "b", "expected": "999"}, {"input": "c", "expected": "42"}],
|
||||
)
|
||||
ds.last_pass_rate = 1.0 # seed a prior high rate so the gate detects the drop
|
||||
await s.commit()
|
||||
rs = RunService(checkpointer=InMemorySaver())
|
||||
report = await EvalService.run(s, rs, ds, regression_gate=True)
|
||||
dsid = ds.id
|
||||
|
||||
# Three items ran (concurrently); two expect "42" (present) and one expects "999".
|
||||
assert report["summary"]["total"] == 3
|
||||
assert report["summary"]["passed"] == 2
|
||||
assert report["summary"]["eval_run_id"]
|
||||
assert report["summary"]["regression"]["regressed"] is True # 0.667 < 1.0
|
||||
|
||||
async with SessionLocal() as s:
|
||||
runs = await EvalService.history(s, "t_h", dsid)
|
||||
assert len(runs) == 1
|
||||
assert runs[0].total == 3 and runs[0].passed == 2 and runs[0].prev_pass_rate == 1.0
|
||||
results = await EvalService.results(s, "t_h", runs[0].id)
|
||||
assert sorted(r.item_index for r in results) == [0, 1, 2]
|
||||
assert any(r.answer and "42" in r.answer for r in results)
|
||||
|
||||
|
||||
# --- F3: richer scorers + per-item assertion lists (AND/OR) ---
|
||||
|
||||
|
||||
def test_numeric_and_json_scorers():
|
||||
ok, err = _score_numeric("about 42.5 units", "42", tolerance=1.0)
|
||||
assert ok is True and err is not None
|
||||
assert _score_numeric("100", "42", tolerance=1.0)[0] is False
|
||||
assert _score_numeric("100", "90", rel_tolerance=0.2)[0] is True # 10 <= 90*0.2
|
||||
assert _score_json('{"a": 1, "b": 2}', {"a": 1}) is True # subset match
|
||||
assert _score_json('{"a": 1}', {"a": 1, "b": 2}) is False
|
||||
assert _score_json('{"a": 1}', {"a": 1}, mode="exact") is True
|
||||
assert _score_json("not json", {"a": 1}) is False
|
||||
|
||||
|
||||
async def test_assertion_list_and_or_combine():
|
||||
ds = Dataset(tenant_id="t", project_id="p", name="x", score_mode="contains", items=[])
|
||||
item = {"input": "q", "expected": "42",
|
||||
"assertions": [{"type": "contains", "expected": "42"}, {"type": "contains", "expected": "zzz"}]}
|
||||
# AND (default): one check fails -> item fails.
|
||||
res_all = await EvalService._score_item(ds, {**item, "assert": "all"}, "the answer is 42", judge_model=None, embedder=None)
|
||||
assert res_all["passed"] is False and len(res_all["checks"]) == 2
|
||||
# OR: one check passes -> item passes.
|
||||
res_any = await EvalService._score_item(ds, {**item, "assert": "any"}, "the answer is 42", judge_model=None, embedder=None)
|
||||
assert res_any["passed"] is True
|
||||
|
||||
|
||||
# --- F4: LLM judge robustness (unavailable != silent contains pass) ---
|
||||
|
||||
|
||||
async def test_judge_unavailable_is_not_a_pass():
|
||||
assert _is_real_judge(None) is False
|
||||
assert _is_real_judge(make_fake_model("anything")) is False # offline fake model is NOT a real judge
|
||||
|
||||
passed, _reason, status = await EvalService._judge(None, "in", "expected", "answer")
|
||||
assert passed is False and status == "unavailable"
|
||||
|
||||
# The answer literally CONTAINS the expected string; the old code fell back to `contains`
|
||||
# here and reported a (misleading) pass. Now a judge item with no model is "unavailable".
|
||||
ds = Dataset(tenant_id="t", project_id="p", name="x", score_mode="judge", items=[])
|
||||
res = await EvalService._score_item(ds, {"input": "i", "expected": "exp"}, "exp appears here",
|
||||
judge_model=None, embedder=None)
|
||||
assert res["passed"] is False and res["status"] == "unavailable"
|
||||
|
||||
|
||||
async def test_embedding_assertion_unavailable_without_embedder():
|
||||
ds = Dataset(tenant_id="t", project_id="p", name="x", score_mode="contains", items=[])
|
||||
item = {"input": "i", "expected": "e", "assertions": [{"type": "embedding", "expected": "e", "threshold": 0.9}]}
|
||||
res = await EvalService._score_item(ds, item, "some answer", judge_model=None, embedder=None)
|
||||
assert res["passed"] is False and res["status"] == "unavailable"
|
||||
|
||||
|
||||
# --- F5: tracer completeness (chain filtering, retriever + embedding spans) ---
|
||||
|
||||
|
||||
def test_tracer_filters_internal_chains_and_reparents():
|
||||
from forge.tracing.tracer import ForgeTracer, _is_internal_chain
|
||||
|
||||
assert _is_internal_chain("RunnableSequence") and _is_internal_chain("chain") and _is_internal_chain("__start__")
|
||||
assert not _is_internal_chain("agent_1") and not _is_internal_chain("support_agent")
|
||||
|
||||
tr = ForgeTracer()
|
||||
tr.on_chain_start({"name": "RunnableSequence"}, {}, run_id="r1", parent_run_id=None) # skipped
|
||||
tr.on_chain_start({"name": "agent_1"}, {}, run_id="r2", parent_run_id="r1") # kept, re-parented
|
||||
tr.on_chat_model_start({}, [], run_id="r3", parent_run_id="r1") # kept, re-parented
|
||||
|
||||
names = [s.name for s in tr.ordered()]
|
||||
assert "RunnableSequence" not in names
|
||||
assert tr.spans["r2"].kind == "chain" and tr.spans["r2"].parent_id is None # skipped r1 had no real parent
|
||||
assert tr.spans["r3"].kind == "llm" and tr.spans["r3"].parent_id is None
|
||||
|
||||
|
||||
def test_tracer_retriever_and_embedding_spans():
|
||||
from forge.tracing.tracer import ForgeTracer
|
||||
from forge.tracing.tracer import embedding_span as active_embedding_span
|
||||
|
||||
tr = ForgeTracer()
|
||||
tr.on_retriever_start({"name": "kb"}, "my query", run_id="rr", parent_run_id=None)
|
||||
tr.on_retriever_end([{"page_content": "doc one"}, {"page_content": "doc two"}], run_id="rr")
|
||||
rspan = tr.spans["rr"]
|
||||
assert rspan.kind == "retriever" and rspan.attributes.get("docs") == 2
|
||||
|
||||
# Embedding span is priced via the embedding rate in pricing.price and rolled into totals.
|
||||
with active_embedding_span("openai:text-embedding-3-small", n_texts=3, input_tokens=1000):
|
||||
pass
|
||||
emb = [s for s in tr.ordered() if s.kind == "embedding"]
|
||||
assert emb and emb[0].cost_usd > 0 and emb[0].attributes.get("n_texts") == 3
|
||||
|
||||
|
||||
# --- F6: quota - projected-cost ceiling, per-project scoping ---
|
||||
|
||||
|
||||
async def test_quota_projected_cost_reserves_for_inflight_runs():
|
||||
async with SessionLocal() as s:
|
||||
t = Tenant(name="PC", settings={"max_cost_per_day_usd": 1.0, "projected_run_cost_usd": 5.0})
|
||||
s.add(t)
|
||||
await s.flush()
|
||||
# An in-flight run is still $0 booked; the reservation (1 * $5) alone must trip the cap.
|
||||
s.add(Run(tenant_id=t.id, project_id="p", workflow_id="w", thread_id="th", status="running", total_cost_usd=0.0))
|
||||
await s.commit()
|
||||
tid = t.id
|
||||
async with SessionLocal() as s:
|
||||
with pytest.raises(QuotaExceeded):
|
||||
await check_run_quota(s, tid)
|
||||
u = await usage_today(s, tid)
|
||||
assert u["inflight"] == 1 and u["reserved_cost_usd"] == 5.0
|
||||
|
||||
|
||||
async def test_quota_no_reservation_when_projected_unset():
|
||||
async with SessionLocal() as s:
|
||||
t = Tenant(name="PC2", settings={"max_cost_per_day_usd": 1.0}) # no projected reservation
|
||||
s.add(t)
|
||||
await s.flush()
|
||||
s.add(Run(tenant_id=t.id, project_id="p", workflow_id="w", thread_id="th", status="running", total_cost_usd=0.0))
|
||||
await s.commit()
|
||||
tid = t.id
|
||||
async with SessionLocal() as s:
|
||||
await check_run_quota(s, tid) # booked cost 0 < 1 and nothing reserved -> must not raise
|
||||
|
||||
|
||||
async def test_quota_per_project_scoping():
|
||||
async with SessionLocal() as s:
|
||||
t = Tenant(name="PP", settings={"project_limits": {"proj_a": {"max_runs_per_day": 1}}})
|
||||
s.add(t)
|
||||
await s.flush()
|
||||
s.add(Run(tenant_id=t.id, project_id="proj_a", workflow_id="w", thread_id="th", status="done"))
|
||||
await s.commit()
|
||||
tid = t.id
|
||||
async with SessionLocal() as s:
|
||||
with pytest.raises(QuotaExceeded):
|
||||
await check_run_quota(s, tid, project_id="proj_a") # per-project cap hit
|
||||
# A project with no per-project limits (and no tenant-wide caps) is unlimited.
|
||||
await check_run_quota(s, tid, project_id="proj_b")
|
||||
|
||||
|
||||
# --- F7: assistant build coverage (new tool kinds + component; offline judge unverified) ---
|
||||
|
||||
|
||||
async def test_assistant_builds_all_tool_kinds_and_component():
|
||||
from forge.services.assistant import build_assistant_tools
|
||||
|
||||
tools = {t.name: t for t in build_assistant_tools("t_ab", "p_ab", [])}
|
||||
for name in ("create_graphql_tool", "create_sql_tool", "create_code_tool", "create_mcp_tool", "create_component"):
|
||||
assert name in tools
|
||||
|
||||
await tools["create_graphql_tool"].ainvoke({"name": "gql", "endpoint": "https://x/graphql", "query": "{ me }", "variables": "id"})
|
||||
await tools["create_sql_tool"].ainvoke({"name": "sqltool", "query": "select 1", "arg_names": "limit"})
|
||||
await tools["create_code_tool"].ainvoke({"name": "codetool", "source": "result = 1"})
|
||||
await tools["create_mcp_tool"].ainvoke({"name": "mcpsrv", "url": "https://x/mcp"})
|
||||
await tools["create_component"].ainvoke({"name": "card", "html": "<div>{{title}}</div>", "props_schema_json": '{"type": "object"}'})
|
||||
|
||||
async with SessionLocal() as s:
|
||||
kinds = {t.kind for t in (await s.execute(select(Tool).where(Tool.project_id == "p_ab"))).scalars()}
|
||||
assert {"graphql", "sql", "code"} <= kinds
|
||||
assert (await s.execute(select(McpClient).where(McpClient.project_id == "p_ab"))).scalars().first() is not None
|
||||
assert (await s.execute(select(Component).where(Component.project_id == "p_ab"))).scalars().first() is not None
|
||||
|
||||
|
||||
async def test_rest_tool_supports_body_query_header_params():
|
||||
from forge.services.assistant import build_assistant_tools
|
||||
|
||||
tools = {t.name: t for t in build_assistant_tools("t_rt", "p_rt", [])}
|
||||
await tools["create_rest_tool"].ainvoke({
|
||||
"name": "post_it", "url_template": "https://x/{id}", "method": "POST",
|
||||
"query_params": "q1", "header_params": "X-H", "body_params": "b1,b2",
|
||||
})
|
||||
async with SessionLocal() as s:
|
||||
tool = (await s.execute(select(Tool).where(Tool.project_id == "p_rt"))).scalars().first()
|
||||
fields = tool.config["request"]["fields"]
|
||||
assert sorted({f["in"] for f in fields}) == ["body", "header", "path", "query"]
|
||||
assert tool.config["request"]["method"] == "POST"
|
||||
|
||||
|
||||
async def test_evaluate_build_offline_is_unverified():
|
||||
from forge.services.assistant import build_assistant_tools
|
||||
|
||||
tools = {t.name: t for t in build_assistant_tools("t_eb", "p_eb", [])}
|
||||
out = json.loads(await tools["evaluate_build"].ainvoke(
|
||||
{"user_request": "x", "what_was_built": "y", "test_results": "z"}
|
||||
))
|
||||
# No provider key -> offline model -> NOT a bogus 'pass'.
|
||||
assert out["verdict"] == "unverified"
|
||||
@@ -0,0 +1,153 @@
|
||||
"""application/x-www-form-urlencoded request bodies (generic form-encoded POST support).
|
||||
|
||||
`request.body_encoding = "form"` routes a structured body through httpx's `data=`, which
|
||||
URL-encodes every value (spaces, =, &, newlines, unicode), emits list values as repeated
|
||||
keys, and sets the Content-Type. This is generic to any form-encoded endpoint; the tests
|
||||
use a classic authenticated form post (orderNum + a multi-line productCodePost + a
|
||||
ctx-injected CSRFToken) as a representative shape, not a hardcoded API.
|
||||
"""
|
||||
|
||||
import json
|
||||
from urllib.parse import parse_qs, parse_qsl
|
||||
|
||||
import httpx
|
||||
|
||||
from forge.tools.rest import build_args_schema, execute_rest
|
||||
from forge.util.ssrf import EgressPolicy
|
||||
|
||||
# Permissive policy: the capturing client short-circuits the network, and block_private=False
|
||||
# keeps the SSRF guard from doing a real DNS lookup on the example host.
|
||||
_POLICY = EgressPolicy(block_private=False)
|
||||
|
||||
|
||||
def _capturing_client(sink: dict) -> httpx.AsyncClient:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
sink["request"] = request
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def _form_cfg(fields, **req_extra) -> dict:
|
||||
return {
|
||||
"name": "form_post",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url_template": "https://portal.example.dev/shop/products/add",
|
||||
"body_encoding": "form",
|
||||
"fields": fields,
|
||||
"headers": [],
|
||||
**req_extra,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def test_form_encoding_urlencodes_multiline_and_special_chars():
|
||||
"""Headline case: a multi-line productCodePost value with `=`, spaces and a newline is
|
||||
URL-encoded correctly and round-trips through a form parser - and the Content-Type is set
|
||||
for the caller (no manual header)."""
|
||||
sink: dict = {}
|
||||
cfg = _form_cfg([
|
||||
{"path": "orderNum", "type": "string", "in": "body", "llm_visible": True},
|
||||
{"path": "pasteOption", "type": "string", "in": "body", "llm_visible": True},
|
||||
{"path": "productCodePost", "type": "string", "in": "body", "llm_visible": True},
|
||||
])
|
||||
multiline = "GLC-TE= 2\nFPR-9= 5"
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(
|
||||
cfg,
|
||||
{"orderNum": "ORD-001", "pasteOption": "P-Q", "productCodePost": multiline},
|
||||
tenant_id="t", project_id="p", client=client, egress_policy=_POLICY,
|
||||
)
|
||||
req = sink["request"]
|
||||
assert req.headers["content-type"] == "application/x-www-form-urlencoded"
|
||||
parsed = parse_qs(req.content.decode(), keep_blank_values=True)
|
||||
assert parsed["orderNum"] == ["ORD-001"]
|
||||
assert parsed["pasteOption"] == ["P-Q"]
|
||||
assert parsed["productCodePost"] == [multiline] # newline, `=`, space survived encode->decode
|
||||
# The wire body must actually be percent-encoded, not literal, or a strict server misparses.
|
||||
assert b"\n" not in req.content and b" " not in req.content
|
||||
|
||||
|
||||
async def test_form_encoding_includes_empty_field_and_ctx_injected_secret():
|
||||
"""An empty field is sent as `key=` (present, blank), and a hidden {{ctx.*}} in:body field
|
||||
is injected into the encoded body without ever being an LLM arg."""
|
||||
sink: dict = {}
|
||||
cfg = _form_cfg([
|
||||
{"path": "decimalPoints", "type": "string", "in": "body", "llm_visible": True, "default": ""},
|
||||
{"path": "CSRFToken", "type": "string", "in": "body", "llm_visible": False, "default": "{{ctx.csrf}}"},
|
||||
])
|
||||
assert "CSRFToken" not in build_args_schema(cfg).model_fields # server-injected, not an LLM arg
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(cfg, {}, tenant_id="t", project_id="p",
|
||||
context={"csrf": "ffbe7ee3-29ff"}, client=client, egress_policy=_POLICY)
|
||||
parsed = parse_qs(sink["request"].content.decode(), keep_blank_values=True)
|
||||
assert parsed["decimalPoints"] == [""] # empty value present on the wire
|
||||
assert parsed["CSRFToken"] == ["ffbe7ee3-29ff"]
|
||||
|
||||
|
||||
async def test_form_encoding_list_value_becomes_repeated_keys():
|
||||
"""Generic repeated-key support: a list value serializes as `k=a&k=b` (not JSON)."""
|
||||
sink: dict = {}
|
||||
cfg = _form_cfg([{"path": "productCodePost", "type": "array", "in": "body", "llm_visible": True}])
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(cfg, {"productCodePost": ["GLC-TE= 2", "FPR-9= 5"]},
|
||||
tenant_id="t", project_id="p", client=client, egress_policy=_POLICY)
|
||||
pairs = parse_qsl(sink["request"].content.decode(), keep_blank_values=True)
|
||||
assert pairs == [("productCodePost", "GLC-TE= 2"), ("productCodePost", "FPR-9= 5")]
|
||||
|
||||
|
||||
async def test_form_encoding_inferred_from_content_type_header():
|
||||
"""With no explicit body_encoding, a declared urlencoded Content-Type + structured body
|
||||
infers form encoding."""
|
||||
sink: dict = {}
|
||||
cfg = {
|
||||
"name": "x",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url_template": "https://portal.example.dev/x",
|
||||
"fields": [{"path": "a", "type": "string", "in": "body", "llm_visible": True}],
|
||||
"headers": [{"name": "Content-Type", "value": "application/x-www-form-urlencoded"}],
|
||||
},
|
||||
}
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(cfg, {"a": "b c"}, tenant_id="t", project_id="p", client=client, egress_policy=_POLICY)
|
||||
assert parse_qs(sink["request"].content.decode())["a"] == ["b c"]
|
||||
|
||||
|
||||
# --- regressions: an unset body_encoding keeps the legacy behavior -------------------------
|
||||
|
||||
|
||||
async def test_default_structured_body_still_json():
|
||||
sink: dict = {}
|
||||
cfg = {
|
||||
"name": "x",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url_template": "https://portal.example.dev/x",
|
||||
"fields": [{"path": "amount", "type": "integer", "in": "body", "llm_visible": True}],
|
||||
"headers": [],
|
||||
},
|
||||
}
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(cfg, {"amount": 5}, tenant_id="t", project_id="p", client=client, egress_policy=_POLICY)
|
||||
assert json.loads(sink["request"].content) == {"amount": 5}
|
||||
assert sink["request"].headers["content-type"].startswith("application/json")
|
||||
|
||||
|
||||
async def test_raw_body_template_unchanged():
|
||||
sink: dict = {}
|
||||
cfg = {
|
||||
"name": "x",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url_template": "https://portal.example.dev/x",
|
||||
"fields": [],
|
||||
"headers": [],
|
||||
"body_template": "CSRFToken={{ctx.csrf}}&scope=all",
|
||||
},
|
||||
}
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(cfg, {}, tenant_id="t", project_id="p", context={"csrf": "C1"},
|
||||
client=client, egress_policy=_POLICY)
|
||||
assert sink["request"].content == b"CSRFToken=C1&scope=all"
|
||||
@@ -0,0 +1,459 @@
|
||||
"""Regression tests for the "ghost config" audit fixes - node/middleware schema options that
|
||||
were exposed in the UI but silently ignored by the compiler, plus the new validation rules.
|
||||
|
||||
Everything here is engine-only (compile_workflow / validate_workflow with fake models and an
|
||||
InMemorySaver), so no database or network is needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.errors import GraphBubbleUp
|
||||
|
||||
from forge.engine.compiler import compile_workflow
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.nodes.flow import FANOUT_INDEX_KEY, join_factory, resilient_fanout_child
|
||||
from forge.services.validation import validate_workflow
|
||||
|
||||
|
||||
def _ctx() -> CompileContext:
|
||||
return CompileContext(tenant_id="t1", project_id="p1", checkpointer=InMemorySaver())
|
||||
|
||||
|
||||
def _cfg(thread: str) -> dict:
|
||||
return {"configurable": {"thread_id": thread}}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Finding 1: join `reducer` is honored (was a pure passthrough).
|
||||
# --------------------------------------------------------------------------------------------
|
||||
|
||||
def test_join_reducer_merge_first_last_concat():
|
||||
ctx = _ctx()
|
||||
assert join_factory({"reducer": "merge", "input_key": "p", "output_key": "o"}, ctx)(
|
||||
{"p": [{"a": 1}, {"b": 2}]}
|
||||
) == {"o": {"a": 1, "b": 2}}
|
||||
assert join_factory({"reducer": "last", "input_key": "p", "output_key": "o"}, ctx)(
|
||||
{"p": [1, 2, 3]}
|
||||
) == {"o": 3}
|
||||
assert join_factory({"reducer": "first", "input_key": "p", "output_key": "o"}, ctx)(
|
||||
{"p": [1, 2, 3]}
|
||||
) == {"o": 1}
|
||||
assert join_factory({"reducer": "concat", "input_key": "p", "output_key": "o"}, ctx)(
|
||||
{"p": [[1], [2, 3]]}
|
||||
) == {"o": [1, 2, 3]}
|
||||
|
||||
|
||||
def test_join_without_input_key_is_passthrough_marker():
|
||||
# No input_key -> convergence marker (aggregation stays with the state-key reducer).
|
||||
assert join_factory({"reducer": "concat"}, _ctx())({"anything": 1}) == {}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Finding 2: parallel_fanout index tagging + partial-failure isolation + per-item timeout.
|
||||
# --------------------------------------------------------------------------------------------
|
||||
|
||||
_FANOUT_WF = {
|
||||
"id": "fan", "version": 1,
|
||||
"state": {
|
||||
"messages": {"type": "list[message]", "reducer": "add_messages"},
|
||||
"items": {"type": "list[json]", "reducer": "last"},
|
||||
"results": {"type": "list[str]", "reducer": "add"},
|
||||
},
|
||||
"entry_node": "start",
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "config": {}},
|
||||
{"id": "fan", "type": "parallel_fanout", "config": {"over": "items", "child_node": "worker", "item_key": "item"}},
|
||||
{"id": "worker", "type": "transform", "config": {"expression": "[item]", "output_key": "results"}},
|
||||
{"id": "join", "type": "join", "config": {"reducer": "concat"}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"source": "start", "target": "fan"},
|
||||
{"source": "worker", "target": "join"},
|
||||
{"source": "join", "target": "end"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_fanout_still_maps_and_index_tag_does_not_leak():
|
||||
graph = compile_workflow(_FANOUT_WF, _ctx())
|
||||
out = await graph.ainvoke({"items": ["a", "b", "c"]}, _cfg("fan-1"))
|
||||
assert sorted(out["results"]) == ["a", "b", "c"]
|
||||
# The index/total ride only in the child's Send payload; they must not leak to run state.
|
||||
assert FANOUT_INDEX_KEY not in out and "_fanout_total" not in out
|
||||
|
||||
|
||||
async def test_fanout_continue_on_error_still_runs():
|
||||
graph = compile_workflow({**_FANOUT_WF, "error_policy": "continue"}, _ctx())
|
||||
out = await graph.ainvoke({"items": ["x", "y"]}, _cfg("fan-2"))
|
||||
assert sorted(out["results"]) == ["x", "y"]
|
||||
|
||||
|
||||
async def test_resilient_child_isolates_one_failure_but_keeps_the_rest():
|
||||
def child(state):
|
||||
if state.get(FANOUT_INDEX_KEY) == 1:
|
||||
raise ValueError("boom")
|
||||
return {"results": [state["item"]]}
|
||||
|
||||
skip = resilient_fanout_child(child, isolate=True)
|
||||
assert await skip({"item": "a", FANOUT_INDEX_KEY: 0}) == {"results": ["a"]}
|
||||
assert await skip({"item": "b", FANOUT_INDEX_KEY: 1}) == {} # failure isolated
|
||||
|
||||
fail = resilient_fanout_child(child, isolate=False)
|
||||
with pytest.raises(ValueError):
|
||||
await fail({"item": "b", FANOUT_INDEX_KEY: 1})
|
||||
|
||||
|
||||
async def test_resilient_child_propagates_control_flow_and_honors_timeout():
|
||||
async def bubbles(state):
|
||||
raise GraphBubbleUp() # interrupts / Command bubbling must NOT be swallowed
|
||||
|
||||
with pytest.raises(GraphBubbleUp):
|
||||
await resilient_fanout_child(bubbles, isolate=True)({FANOUT_INDEX_KEY: 0})
|
||||
|
||||
async def slow(state):
|
||||
await asyncio.sleep(1)
|
||||
return {"results": ["late"]}
|
||||
|
||||
assert await resilient_fanout_child(slow, timeout=0.05, isolate=True)({FANOUT_INDEX_KEY: 0}) == {}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Finding 3: tenant_budget honors max_usd_per_thread and scopes tokens to the run.
|
||||
# --------------------------------------------------------------------------------------------
|
||||
|
||||
def test_tenant_budget_run_scoped_tokens():
|
||||
from forge.engine.middleware_compiler import _tenant_budget
|
||||
|
||||
mw = _tenant_budget({"max_tokens_per_run": 10, "on_exceed": "end"}, None)
|
||||
msg = AIMessage(content="x", usage_metadata={"input_tokens": 6, "output_tokens": 6, "total_tokens": 12})
|
||||
assert mw.after_model({"messages": [msg]})["_forge_run_tokens"] == 12
|
||||
stop = mw.before_model({"_forge_run_tokens": 12})
|
||||
assert stop and stop.get("jump_to") == "end"
|
||||
assert mw.before_model({"_forge_run_tokens": 0}) is None
|
||||
|
||||
|
||||
def test_tenant_budget_usd_accounting():
|
||||
from forge.engine.middleware_compiler import _tenant_budget
|
||||
|
||||
mw = _tenant_budget({"max_usd_per_thread": 1.0, "on_exceed": "error"}, None)
|
||||
# gpt-4.1-mini input is $0.40/1M tokens -> 1M input tokens == $0.40.
|
||||
msg = AIMessage(
|
||||
content="x",
|
||||
usage_metadata={"input_tokens": 1_000_000, "output_tokens": 0, "total_tokens": 1_000_000},
|
||||
response_metadata={"model_name": "gpt-4.1-mini"},
|
||||
)
|
||||
upd = mw.after_model({"messages": [msg]})
|
||||
assert abs(upd["_forge_thread_cost_usd"] - 0.4) < 1e-6
|
||||
with pytest.raises(RuntimeError):
|
||||
mw.before_model({"_forge_thread_cost_usd": 2.0})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Finding 4: guardrail_regex honors apply_to and implements redact/flag (block still replaces).
|
||||
# --------------------------------------------------------------------------------------------
|
||||
|
||||
def test_guardrail_redact_masks_input_and_output():
|
||||
from forge.engine.middleware_compiler import _guardrail_regex
|
||||
|
||||
mw = _guardrail_regex({"patterns": ["forbidden"], "on_match": "redact", "apply_to": "both"}, None)
|
||||
out = mw.after_model({"messages": [AIMessage(content="the forbidden secret", id="a1")]})
|
||||
assert "[redacted]" in out["messages"][-1].content and "forbidden" not in out["messages"][-1].content
|
||||
inp = mw.before_model({"messages": [HumanMessage(content="my forbidden input", id="h1")]})
|
||||
assert "[redacted]" in inp["messages"][-1].content
|
||||
|
||||
|
||||
def test_guardrail_flag_marks_without_changing_content():
|
||||
from forge.engine.middleware_compiler import _guardrail_regex
|
||||
|
||||
mw = _guardrail_regex({"patterns": ["bad"], "on_match": "flag", "apply_to": "output"}, None)
|
||||
out = mw.after_model({"messages": [AIMessage(content="this is bad", id="a2")]})
|
||||
assert out["messages"][-1].additional_kwargs.get("guardrail_flagged")
|
||||
assert out["messages"][-1].content == "this is bad"
|
||||
|
||||
|
||||
def test_guardrail_output_only_ignores_input():
|
||||
from forge.engine.middleware_compiler import _guardrail_regex
|
||||
|
||||
mw = _guardrail_regex({"patterns": ["forbidden"], "on_match": "block", "apply_to": "output"}, None)
|
||||
assert mw.before_model({"messages": [HumanMessage(content="forbidden", id="h9")]}) is None
|
||||
|
||||
|
||||
async def test_guardrail_block_still_replaces_reply():
|
||||
wf = {
|
||||
"id": "g", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "start",
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "config": {}},
|
||||
{"id": "agent", "type": "agent",
|
||||
"config": {"flavor": "agent", "model": "fake:the forbidden secret",
|
||||
"middleware": [{"type": "guardrail_regex",
|
||||
"config": {"patterns": ["forbidden"], "on_match": "block"}}]}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "start", "target": "agent"}, {"source": "agent", "target": "end"}],
|
||||
}
|
||||
out = await compile_workflow(wf, _ctx()).ainvoke({"messages": [HumanMessage(content="hi")]}, _cfg("g1"))
|
||||
texts = [getattr(m, "content", "") for m in out["messages"]]
|
||||
assert any("[blocked by content guardrail]" in t for t in texts)
|
||||
assert not any("forbidden secret" in t for t in texts)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Finding 5: model_retry passes retry_on through.
|
||||
# --------------------------------------------------------------------------------------------
|
||||
|
||||
def test_model_retry_passes_retry_on():
|
||||
from forge.engine.middleware_compiler import _model_retry
|
||||
|
||||
mw = _model_retry({"max_retries": 1, "retry_on": ["timeout", "http_error"]}, None)
|
||||
assert TimeoutError in mw.retry_on and httpx.HTTPError in mw.retry_on
|
||||
assert _model_retry({"max_retries": 1}, None).retry_on == (Exception,)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Advanced middleware are async-safe now (were sync-only -> crashed under ainvoke).
|
||||
# --------------------------------------------------------------------------------------------
|
||||
|
||||
async def test_dynamic_model_by_state_middleware_runs_async():
|
||||
# The advanced middleware were sync-only and raised NotImplementedError under ainvoke/astream
|
||||
# (the real runtime path). They must now run async AND actually apply the model override.
|
||||
# Rules evaluate against the AGENT's visible state; switching on an arbitrary parent-workflow
|
||||
# state key is a separate, documented limitation (the agent subgraph boundary doesn't forward
|
||||
# it), so this asserts the mechanism over a rule the agent can evaluate.
|
||||
def wf(rule_when: str) -> dict:
|
||||
return {
|
||||
"id": "dm", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "start",
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "config": {}},
|
||||
{"id": "agent", "type": "agent", "config": {
|
||||
"flavor": "agent", "model": "fake:base",
|
||||
"middleware": [{"type": "dynamic_model_by_state",
|
||||
"config": {"rules": [{"when": rule_when, "use": "fake:switched"}], "default": "fake:base"}}],
|
||||
}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "start", "target": "agent"}, {"source": "agent", "target": "end"}],
|
||||
}
|
||||
matched = await compile_workflow(wf("True"), _ctx()).ainvoke({"messages": [HumanMessage(content="hi")]}, _cfg("dm1"))
|
||||
assert any("switched" in getattr(m, "content", "") for m in matched["messages"])
|
||||
default = await compile_workflow(wf("False"), _ctx()).ainvoke({"messages": [HumanMessage(content="hi")]}, _cfg("dm2"))
|
||||
assert any("base" in getattr(m, "content", "") for m in default["messages"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Finding 6: subworkflow input_mapping/output_mapping remap parent<->child keys.
|
||||
# --------------------------------------------------------------------------------------------
|
||||
|
||||
_CHILD = {
|
||||
"id": "child", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"},
|
||||
"child_in": {"type": "str", "reducer": "last"},
|
||||
"child_out": {"type": "str", "reducer": "last"}},
|
||||
"entry_node": "t",
|
||||
"nodes": [
|
||||
{"id": "t", "type": "transform", "config": {"expression": "child_in", "output_key": "child_out"}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "t", "target": "end"}],
|
||||
}
|
||||
|
||||
|
||||
async def test_subworkflow_input_output_mapping():
|
||||
parent = {
|
||||
"id": "parent", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"},
|
||||
"p_val": {"type": "str", "reducer": "last"},
|
||||
"p_result": {"type": "str", "reducer": "last"}},
|
||||
"entry_node": "start",
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "config": {}},
|
||||
{"id": "sub", "type": "subworkflow", "config": {
|
||||
"workflow_id": "child_1",
|
||||
"input_mapping": {"p_val": "child_in"},
|
||||
"output_mapping": {"child_out": "p_result"}}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "start", "target": "sub"}, {"source": "sub", "target": "end"}],
|
||||
}
|
||||
ctx = _ctx()
|
||||
ctx.workflows = {"child_1": _CHILD}
|
||||
out = await compile_workflow(parent, ctx).ainvoke({"p_val": "hello"}, _cfg("sub-map"))
|
||||
assert out.get("p_result") == "hello"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Finding 7: transform engine=jq raises clearly when jq missing; jmespath errors -> None.
|
||||
# --------------------------------------------------------------------------------------------
|
||||
|
||||
def test_transform_jq_raises_when_unavailable():
|
||||
from forge.nodes.data import transform_factory
|
||||
|
||||
node = transform_factory({"engine": "jq", "expression": ".x", "output_key": "data"}, _ctx())
|
||||
with pytest.raises(ValueError, match="jq"):
|
||||
node({"x": 1})
|
||||
|
||||
|
||||
def test_transform_bad_jmespath_returns_none():
|
||||
from forge.nodes.data import transform_factory
|
||||
|
||||
node = transform_factory({"expression": "foo[", "output_key": "data"}, _ctx())
|
||||
assert node({"foo": 1}) == {"data": None}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Finding 10: new validation rules (+ the pre-existing fanout-adjacency false positive).
|
||||
# --------------------------------------------------------------------------------------------
|
||||
|
||||
def _wf_with(nodes, edges, state=None):
|
||||
return {
|
||||
"id": "v", "version": 1,
|
||||
"state": state or {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "start", "nodes": nodes, "edges": edges,
|
||||
}
|
||||
|
||||
|
||||
def test_fanout_workflow_validates_cleanly():
|
||||
# Pre-existing bug: the validator didn't model parallel_fanout -> child, so it wrongly
|
||||
# reported worker/join/end unreachable + "no path to END". It should validate now.
|
||||
res = validate_workflow(_FANOUT_WF)
|
||||
assert res.valid, res.errors
|
||||
|
||||
|
||||
def test_undeclared_write_is_an_error():
|
||||
wf = _wf_with(
|
||||
[
|
||||
{"id": "start", "type": "start", "config": {}},
|
||||
{"id": "x", "type": "transform", "config": {"expression": "`1`", "output_key": "ghost_key"}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
[{"source": "start", "target": "x"}, {"source": "x", "target": "end"}],
|
||||
)
|
||||
res = validate_workflow(wf)
|
||||
assert not res.valid
|
||||
assert any("ghost_key" in e["message"] for e in res.errors)
|
||||
# Declaring it clears the error.
|
||||
wf2 = _wf_with(
|
||||
wf["nodes"], wf["edges"],
|
||||
state={"messages": {"type": "list[message]", "reducer": "add_messages"},
|
||||
"ghost_key": {"type": "json", "reducer": "last"}},
|
||||
)
|
||||
assert validate_workflow(wf2).valid
|
||||
|
||||
|
||||
def test_reachable_dead_end_warns():
|
||||
wf = _wf_with(
|
||||
[
|
||||
{"id": "start", "type": "start", "config": {}},
|
||||
{"id": "a", "type": "agent", "config": {"flavor": "agent", "model": "fake:x"}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
# 'a' is reachable but has no outgoing edge; start also reaches end so the graph is valid.
|
||||
[{"source": "start", "target": "a"}, {"source": "start", "target": "end"}],
|
||||
)
|
||||
res = validate_workflow(wf)
|
||||
assert any("no outgoing edge" in w["message"] and w.get("node_id") == "a" for w in res.warnings)
|
||||
|
||||
|
||||
def test_branches_edge_requires_condition():
|
||||
wf = _wf_with(
|
||||
[
|
||||
{"id": "start", "type": "start", "config": {}},
|
||||
{"id": "a", "type": "agent", "config": {"flavor": "agent", "model": "fake:x"}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
[
|
||||
{"source": "start", "target": "a"},
|
||||
{"source": "a", "target": "end", "branches": {"yes": "end"}}, # missing condition
|
||||
],
|
||||
)
|
||||
res = validate_workflow(wf)
|
||||
assert not res.valid
|
||||
assert any("no condition" in e["message"] for e in res.errors)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Finding 8: a branches edge routes on its condition, and an unmatched value ends the run
|
||||
# gracefully (END is a valid target) instead of raising KeyError('__end__') at runtime.
|
||||
# --------------------------------------------------------------------------------------------
|
||||
|
||||
def _branch_wf():
|
||||
return {
|
||||
"id": "br", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"},
|
||||
"intent": {"type": "str", "reducer": "last"}},
|
||||
"entry_node": "start",
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "config": {}},
|
||||
{"id": "gate", "type": "transform", "config": {"expression": "intent", "output_key": "intent"}},
|
||||
{"id": "a", "type": "agent", "config": {"flavor": "agent", "model": "fake:A-ANSWER"}},
|
||||
{"id": "b", "type": "agent", "config": {"flavor": "agent", "model": "fake:B-ANSWER"}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"source": "start", "target": "gate"},
|
||||
{"source": "gate", "target": "end", "condition": "intent", "branches": {"x": "a", "y": "b"}},
|
||||
{"source": "a", "target": "end"},
|
||||
{"source": "b", "target": "end"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_branch_edge_routes_and_ends_gracefully_on_no_match():
|
||||
graph = compile_workflow(_branch_wf(), _ctx())
|
||||
out = await graph.ainvoke({"messages": [HumanMessage(content="hi")], "intent": "y"}, _cfg("br-y"))
|
||||
texts = [getattr(m, "content", "") for m in out["messages"]]
|
||||
assert any("B-ANSWER" in t for t in texts) and not any("A-ANSWER" in t for t in texts)
|
||||
# Unmatched value must route to END without crashing (was KeyError('__end__')).
|
||||
out2 = await graph.ainvoke({"messages": [HumanMessage(content="hi")], "intent": "z"}, _cfg("br-z"))
|
||||
assert not any("ANSWER" in getattr(m, "content", "") for m in out2["messages"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Finding 9: unwired agent fields (memory/filesystem/permissions) surface as warnings.
|
||||
# --------------------------------------------------------------------------------------------
|
||||
|
||||
def test_unwired_agent_fields_warn():
|
||||
wf = _wf_with(
|
||||
[
|
||||
{"id": "start", "type": "start", "config": {}},
|
||||
{"id": "a", "type": "agent", "config": {
|
||||
"flavor": "agent", "model": "fake:x",
|
||||
"permissions": [{"path": "/x", "access": "read"}],
|
||||
"memory": {"long_term": True}}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
[{"source": "start", "target": "a"}, {"source": "a", "target": "end"}],
|
||||
)
|
||||
res = validate_workflow(wf)
|
||||
assert res.valid # warnings only, never block save
|
||||
assert any("permissions" in w["message"] for w in res.warnings)
|
||||
assert any("memory" in w["message"].lower() for w in res.warnings)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Library drift: langchain-openai (>=1.3) renamed OpenAIModerationMiddleware's apply_to_* flags
|
||||
# to check_*. Enabling `openai_moderation` used to crash at compile with
|
||||
# "__init__() got an unexpected keyword argument 'apply_to_input'"; the compiler now translates.
|
||||
# --------------------------------------------------------------------------------------------
|
||||
|
||||
def test_openai_moderation_translates_apply_to_flags():
|
||||
pytest.importorskip("langchain_openai")
|
||||
from forge.engine.middleware_compiler import _openai_moderation
|
||||
|
||||
mw = _openai_moderation({"apply_to_input": True, "apply_to_output": False}, None)
|
||||
assert mw.check_input is True and mw.check_output is False
|
||||
# Empty config compiles to the library defaults (both checks on) without raising.
|
||||
default = _openai_moderation({}, None)
|
||||
assert default.check_input is True and default.check_output is True
|
||||
# Advanced-JSON pass-through kwargs reach the library unchanged.
|
||||
assert _openai_moderation({"exit_behavior": "replace"}, None).exit_behavior == "replace"
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Live-agent handoff: a channel run pauses at a handoff node, opens a queue item,
|
||||
and an agent reply resumes the run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import HandoffRequest, Workflow
|
||||
from forge.services.channels import ChannelService
|
||||
from forge.services.dispatch import dispatch_message
|
||||
from forge.services.handoff import HandoffService
|
||||
from forge.services.runs import RunService
|
||||
|
||||
_WF = {
|
||||
"id": "wf_h", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "handoff",
|
||||
"nodes": [
|
||||
{"id": "handoff", "type": "handoff", "config": {"reason": "needs a human", "ack_message": "Hold on, connecting you."}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "handoff", "target": "end"}],
|
||||
}
|
||||
|
||||
|
||||
async def test_handoff_node_interrupts_then_resumes():
|
||||
async with SessionLocal() as s:
|
||||
wf = Workflow(tenant_id="t_h", project_id="p_h", name="H", executable=_WF, status="active")
|
||||
s.add(wf)
|
||||
await s.commit()
|
||||
await s.refresh(wf)
|
||||
ch = await ChannelService.create(s, "t_h", "p_h", type_="email", name="W", workflow_id=wf.id)
|
||||
|
||||
# shared checkpointer so the run can be resumed by id
|
||||
rs = RunService(checkpointer=InMemorySaver())
|
||||
result = await dispatch_message(rs, tenant_id="t_h", project_id="p_h", workflow_id=wf.id, text="I need help")
|
||||
assert result["interrupted"] is True
|
||||
# the interrupt carries our handoff marker + reason
|
||||
flat = [it for grp in result["interrupts"] for it in grp]
|
||||
assert any(isinstance(i.get("value"), dict) and i["value"].get("handoff") for i in flat)
|
||||
|
||||
# open a handoff queue item and have an agent reply
|
||||
async with SessionLocal() as s:
|
||||
h = await HandoffService.create(
|
||||
s, channel=ch, tenant_id="t_h", project_id="p_h", workflow_id=wf.id,
|
||||
run_id=result["run_id"], thread_id=result["thread_id"], customer="widget-user",
|
||||
customer_message="I need help", reason="needs a human", reply_context={},
|
||||
)
|
||||
out = await HandoffService.reply(s, rs, handoff=h, agent_id="agent1", message="Hi, this is Sam - happy to help!")
|
||||
assert out["ok"] is True
|
||||
refreshed = await s.get(HandoffRequest, h.id)
|
||||
assert refreshed.status == "answered" and refreshed.agent_id == "agent1"
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Hybrid retrieval: RRF fusion + BM25 primitives and the end-to-end search(hybrid=True)
|
||||
path (scoping, score normalization, source filtering, graceful vector fallback)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.knowledge.hybrid import bm25_rank, rrf_fuse
|
||||
from forge.services.knowledge import KnowledgeService
|
||||
|
||||
# --- pure primitives ---
|
||||
|
||||
|
||||
def test_rrf_fuse_rewards_agreement():
|
||||
# "b" is near the top of BOTH lists; "a" and "z" each top only one.
|
||||
fused = rrf_fuse(["a", "b", "c"], ["z", "b", "y"])
|
||||
assert fused["b"] > fused["a"]
|
||||
assert fused["b"] > fused["z"]
|
||||
|
||||
|
||||
def test_bm25_rank_surfaces_exact_term():
|
||||
docs = [
|
||||
("d1", "general refund and shipping policy details"),
|
||||
("d2", "error code XJ9000 means a payment gateway timeout"),
|
||||
("d3", "how to contact our support team"),
|
||||
]
|
||||
assert bm25_rank("XJ9000 gateway timeout", docs)[0] == "d2"
|
||||
|
||||
|
||||
def test_bm25_rank_empty_when_no_overlap():
|
||||
docs = [("d1", "alpha beta gamma"), ("d2", "delta epsilon zeta")]
|
||||
assert bm25_rank("zzz qqq wwww", docs) == []
|
||||
|
||||
|
||||
# --- end-to-end search(hybrid=True) ---
|
||||
|
||||
|
||||
async def test_hybrid_search_scoped_and_normalized(tmp_path):
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma")
|
||||
async with SessionLocal() as s:
|
||||
for i, t in enumerate([
|
||||
"Refunds go to the original payment method within 5-7 business days.",
|
||||
"Error code XJ9000 indicates a payment gateway timeout; retry after 30 seconds.",
|
||||
"Cancel an order from the Orders page before the item ships.",
|
||||
]):
|
||||
src = await KnowledgeService.create_source(s, "t_hy", "p_hy", kind="text", name=f"d{i}", text=t)
|
||||
await KnowledgeService.ingest(s, src)
|
||||
# A different project must never leak into p_hy's results.
|
||||
other = await KnowledgeService.create_source(s, "t_hy", "p_other", kind="text", name="x", text="XJ9000 belongs to another project")
|
||||
await KnowledgeService.ingest(s, other)
|
||||
hits = await KnowledgeService.search(s, "t_hy", "p_hy", "XJ9000 timeout", top_k=3, hybrid=True)
|
||||
assert hits
|
||||
assert all(0 < h.score <= 1.0 for h in hits) # normalized fusion score
|
||||
assert hits[0].score == 1.0 # best fused result anchors at 1.0
|
||||
assert all(h.metadata.get("project_id") == "p_hy" for h in hits) # tenant/project scoped
|
||||
assert any("XJ9000" in h.text for h in hits) # lexical match surfaced
|
||||
|
||||
|
||||
async def test_hybrid_respects_source_filter(tmp_path):
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma2")
|
||||
async with SessionLocal() as s:
|
||||
a = await KnowledgeService.create_source(s, "t_sf", "p_sf", kind="text", name="a", text="XJ9000 appears in source A only")
|
||||
await KnowledgeService.ingest(s, a)
|
||||
b = await KnowledgeService.create_source(s, "t_sf", "p_sf", kind="text", name="b", text="source B is about refunds and shipping")
|
||||
await KnowledgeService.ingest(s, b)
|
||||
hits = await KnowledgeService.search(s, "t_sf", "p_sf", "XJ9000", top_k=5, hybrid=True, source_ids=[a.id])
|
||||
assert hits
|
||||
assert all(h.metadata.get("source_id") == a.id for h in hits) # filter preserved under hybrid
|
||||
|
||||
|
||||
async def test_hybrid_degrades_to_vector_without_lexical_overlap(tmp_path):
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma3")
|
||||
async with SessionLocal() as s:
|
||||
src = await KnowledgeService.create_source(s, "t_dg", "p_dg", kind="text", name="d", text="Refunds are issued within five business days.")
|
||||
await KnowledgeService.ingest(s, src)
|
||||
# Query shares no tokens with the corpus -> BM25 contributes nothing -> vector fallback.
|
||||
hits = await KnowledgeService.search(s, "t_dg", "p_dg", "zzz qqq wwww", top_k=3, hybrid=True)
|
||||
assert len(hits) == 1 # no crash; vector path still returns the doc
|
||||
assert "Refunds" in hits[0].text
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Phase 5 validation: splitter, offline embedder, Q&A lookup, Chroma ingest→search."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.knowledge.embeddings import cosine, resolve_embedder
|
||||
from forge.knowledge.splitter import split_text
|
||||
from forge.services.knowledge import KnowledgeService
|
||||
|
||||
|
||||
def test_splitter_chunks_long_text():
|
||||
text = ("Sentence one. " * 200).strip()
|
||||
chunks = split_text(text, chunk_size=300, overlap=50)
|
||||
assert len(chunks) > 1
|
||||
assert all(len(c) <= 360 for c in chunks) # ~chunk_size + overlap slack
|
||||
|
||||
|
||||
def test_embedder_similarity_reflects_overlap():
|
||||
pytest.importorskip("fastembed")
|
||||
e = resolve_embedder("fastembed:BAAI/bge-small-en-v1.5")
|
||||
if getattr(e, "name", "") != "BAAI/bge-small-en-v1.5":
|
||||
pytest.skip("fastembed model could not be loaded (offline)")
|
||||
a = e.embed_query("refunds are issued to the original payment method")
|
||||
b = e.embed_query("how long do refunds take to be issued")
|
||||
c = e.embed_query("the weather in tokyo is sunny today")
|
||||
assert cosine(a, b) > cosine(a, c) # topical overlap > unrelated
|
||||
|
||||
|
||||
async def test_qa_create_and_lookup():
|
||||
async with SessionLocal() as s:
|
||||
await KnowledgeService.create_qa(s, "t_qa", "p_qa", question="How do I reset my password?", answer="Settings > Security > Reset password.", kind="faq")
|
||||
match = await KnowledgeService.lookup(s, "t_qa", "p_qa", "how to reset password", threshold=0.2)
|
||||
assert match and "Security" in match["answer"]
|
||||
|
||||
|
||||
async def test_ingest_text_and_search(tmp_path):
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma") # isolate Chroma for the test
|
||||
async with SessionLocal() as s:
|
||||
src = await KnowledgeService.create_source(
|
||||
s, "t_kb", "p_kb", kind="text", name="help",
|
||||
text="Refunds are issued to the original payment method within 5-7 business days. "
|
||||
"To cancel an order, open the Orders page before it ships.",
|
||||
)
|
||||
src = await KnowledgeService.ingest(s, src)
|
||||
assert src.status == "ready" and src.chunks >= 1
|
||||
hits = await KnowledgeService.search(s, "t_kb", "p_kb", "how long do refunds take", top_k=3)
|
||||
assert hits, "expected at least one hit"
|
||||
assert "refund" in hits[0].text.lower()
|
||||
@@ -0,0 +1,63 @@
|
||||
"""MCP tool-kind wiring tests (monkeypatched server - no live MCP connection)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from langchain_core.tools import StructuredTool
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import McpClient
|
||||
from forge.services.runtime import make_runtime_ctx
|
||||
from forge.tools import mcp as mcp_mod
|
||||
from forge.tools.mcp import McpUnavailable, load_mcp_tool
|
||||
|
||||
|
||||
def _fake_remote_tool(name="search"):
|
||||
async def _run(q: str) -> str:
|
||||
return f"results for {q}"
|
||||
return StructuredTool.from_function(coroutine=_run, name=name, description="remote search")
|
||||
|
||||
|
||||
async def _make_client(tenant="t_mcp", project="p_mcp") -> str:
|
||||
async with SessionLocal() as s:
|
||||
row = McpClient(tenant_id=tenant, project_id=project, name="demo", transport="streamable_http", url="https://mcp.example/sse")
|
||||
s.add(row)
|
||||
await s.commit()
|
||||
await s.refresh(row)
|
||||
return row.id
|
||||
|
||||
|
||||
async def test_adapters_import_available():
|
||||
# The optional extra is installed in this env; the loader resolves the client class.
|
||||
assert mcp_mod._require_adapters() is not None
|
||||
|
||||
|
||||
async def test_load_mcp_tool_finds_remote_tool(monkeypatch):
|
||||
cid = await _make_client()
|
||||
|
||||
async def fake_client_and_tools(row, tenant_id, project_id):
|
||||
return object(), [_fake_remote_tool("search")]
|
||||
|
||||
monkeypatch.setattr(mcp_mod, "_client_and_tools", fake_client_and_tools)
|
||||
ctx = make_runtime_ctx("t_mcp", "p_mcp")
|
||||
tool = await load_mcp_tool({"mcp_client_id": cid, "remote_tool_name": "search"}, ctx)
|
||||
assert tool.name == "search"
|
||||
assert await tool.ainvoke({"q": "hello"}) == "results for hello"
|
||||
|
||||
|
||||
async def test_load_mcp_tool_unknown_remote(monkeypatch):
|
||||
cid = await _make_client()
|
||||
monkeypatch.setattr(mcp_mod, "_client_and_tools", lambda *a, **k: _noop([]))
|
||||
ctx = make_runtime_ctx("t_mcp", "p_mcp")
|
||||
with pytest.raises(McpUnavailable):
|
||||
await load_mcp_tool({"mcp_client_id": cid, "remote_tool_name": "nope"}, ctx)
|
||||
|
||||
|
||||
async def test_load_mcp_tool_missing_client():
|
||||
ctx = make_runtime_ctx("t_mcp", "p_mcp")
|
||||
with pytest.raises(McpUnavailable):
|
||||
await load_mcp_tool({"mcp_client_id": "does-not-exist", "remote_tool_name": "x"}, ctx)
|
||||
|
||||
|
||||
async def _noop(tools):
|
||||
return object(), tools
|
||||
@@ -0,0 +1,117 @@
|
||||
"""OAuth 2.1 for MCP: discovery, dynamic client registration, authorization-code + PKCE, single-use
|
||||
codes, audience binding, and token validation on the MCP endpoint. Gated by mcp_oauth_enabled."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import urllib.parse
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from forge.config import settings
|
||||
from forge.main import create_app
|
||||
|
||||
|
||||
def _pkce() -> tuple[str, str]:
|
||||
verifier = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode()
|
||||
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
async def _authorize_code(c, *, email, password, client_id, redirect_uri, challenge, resource) -> str:
|
||||
form = {
|
||||
"email": email, "password": password, "workspace_id": "", "client_id": client_id,
|
||||
"redirect_uri": redirect_uri, "code_challenge": challenge, "state": "s", "resource": resource, "scope": "",
|
||||
}
|
||||
sub = await c.post("/v1/oauth/authorize", data=form, follow_redirects=False)
|
||||
assert sub.status_code == 302, sub.text
|
||||
q = urllib.parse.parse_qs(urllib.parse.urlparse(sub.headers["location"]).query)
|
||||
assert q.get("state") == ["s"]
|
||||
return q["code"][0]
|
||||
|
||||
|
||||
async def test_mcp_oauth_disabled_returns_404():
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
assert (await c.get("/.well-known/oauth-authorization-server")).status_code == 404
|
||||
|
||||
|
||||
async def test_mcp_oauth_end_to_end(monkeypatch):
|
||||
monkeypatch.setattr(settings, "mcp_oauth_enabled", True)
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
# a real user + a project (in the user's tenant) with one tool
|
||||
email = f"o{uuid.uuid4().hex[:10]}@example.com"
|
||||
reg = await c.post("/v1/auth/register", json={"email": email, "password": "supersecret1"})
|
||||
assert reg.status_code == 201, reg.text
|
||||
c.headers["Authorization"] = f"Bearer {reg.json()['access_token']}"
|
||||
pid = (await c.post("/v1/projects", json={"name": "OAuth", "slug": "oauth-proj"})).json()["id"]
|
||||
tid = (await c.post(f"/v1/projects/{pid}/tools", json={"name": "calc", "kind": "builtin", "config": {"builtin": "calculator", "description": "c"}})).json()["id"]
|
||||
# publish the tool via an exposed tool set (the MCP surface = exposed sets' enabled tools)
|
||||
await c.post(f"/v1/projects/{pid}/tool-sets", json={"name": "General", "tool_ids": [tid]})
|
||||
|
||||
# discovery is live when enabled
|
||||
asm = (await c.get("/.well-known/oauth-authorization-server")).json()
|
||||
assert asm["code_challenge_methods_supported"] == ["S256"]
|
||||
prm = (await c.get(f"/.well-known/oauth-protected-resource/v1/mcp/{pid}")).json()
|
||||
assert prm["resource"].endswith(f"/v1/mcp/{pid}") and prm["authorization_servers"]
|
||||
|
||||
# dynamic client registration (RFC 7591)
|
||||
redirect_uri = "http://localhost/callback"
|
||||
rc = await c.post("/v1/oauth/register", json={"redirect_uris": [redirect_uri], "client_name": "Test client"})
|
||||
assert rc.status_code == 201, rc.text
|
||||
client_id = rc.json()["client_id"]
|
||||
|
||||
# the consent form renders
|
||||
verifier, challenge = _pkce()
|
||||
resource = f"{settings.public_base_url.rstrip('/')}/v1/mcp/{pid}"
|
||||
gf = await c.get("/v1/oauth/authorize", params={
|
||||
"response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri,
|
||||
"code_challenge": challenge, "code_challenge_method": "S256", "resource": resource, "state": "s",
|
||||
})
|
||||
assert gf.status_code == 200 and "Authorize" in gf.text
|
||||
|
||||
# authorization-code exchange (PKCE verifier) -> access token
|
||||
code = await _authorize_code(c, email=email, password="supersecret1", client_id=client_id,
|
||||
redirect_uri=redirect_uri, challenge=challenge, resource=resource)
|
||||
tok = await c.post("/v1/oauth/token", data={
|
||||
"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri,
|
||||
"client_id": client_id, "code_verifier": verifier,
|
||||
})
|
||||
assert tok.status_code == 200, tok.text
|
||||
access = tok.json()["access_token"]
|
||||
assert tok.json()["token_type"] == "Bearer"
|
||||
|
||||
# the audience-bound token authorizes the MCP endpoint
|
||||
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
|
||||
oauth_headers = {"Authorization": f"Bearer {access}"}
|
||||
r = await c.post(f"/v1/mcp/{pid}", headers=oauth_headers, json=body)
|
||||
assert r.status_code == 200 and any(t["name"] == "calc" for t in r.json()["result"]["tools"])
|
||||
|
||||
# no credential -> 401 with an RFC 9728 discovery pointer
|
||||
no = await c.post(f"/v1/mcp/{pid}", json=body)
|
||||
assert no.status_code == 401 and "resource_metadata" in no.headers.get("www-authenticate", "")
|
||||
|
||||
# audience binding: the token must not work on a different project
|
||||
pid2 = (await c.post("/v1/projects", json={"name": "Other", "slug": "oauth-other"})).json()["id"]
|
||||
assert (await c.post(f"/v1/mcp/{pid2}", headers=oauth_headers, json=body)).status_code == 401
|
||||
|
||||
# single-use: replaying the same authorization code is rejected
|
||||
replay = await c.post("/v1/oauth/token", data={
|
||||
"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri,
|
||||
"client_id": client_id, "code_verifier": verifier,
|
||||
})
|
||||
assert replay.status_code == 400
|
||||
|
||||
# PKCE is enforced: a fresh code with the wrong verifier fails
|
||||
v2, ch2 = _pkce()
|
||||
code2 = await _authorize_code(c, email=email, password="supersecret1", client_id=client_id,
|
||||
redirect_uri=redirect_uri, challenge=ch2, resource=resource)
|
||||
bad = await c.post("/v1/oauth/token", data={
|
||||
"grant_type": "authorization_code", "code": code2, "redirect_uri": redirect_uri,
|
||||
"client_id": client_id, "code_verifier": verifier, # wrong verifier
|
||||
})
|
||||
assert bad.status_code == 400
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Forge-as-an-MCP-server: initialize / tools/list / tools/call over JSON-RPC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.main import create_app
|
||||
from forge.models import Project, Tool
|
||||
|
||||
|
||||
async def _seed_project_with_tool(slug="mcp-proj") -> str:
|
||||
from forge.services.tool_sets import ToolSetService
|
||||
|
||||
async with SessionLocal() as s:
|
||||
proj = Project(tenant_id="t_mcps", name="MCP Proj", slug=slug, config={})
|
||||
s.add(proj)
|
||||
await s.flush()
|
||||
tool = Tool(tenant_id="t_mcps", project_id=proj.id, name="calculator", kind="builtin",
|
||||
config={"builtin": "calculator", "description": "Evaluate arithmetic."})
|
||||
s.add(tool)
|
||||
await s.commit()
|
||||
await s.refresh(proj)
|
||||
await s.refresh(tool)
|
||||
# The MCP surface is the enabled tools of EXPOSED tool sets, so publish via a set.
|
||||
await ToolSetService.create(s, "t_mcps", proj.id, name="General", tool_ids=[tool.id])
|
||||
return proj.id
|
||||
|
||||
|
||||
async def test_mcp_initialize_and_list_and_call():
|
||||
pid = await _seed_project_with_tool()
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
# initialize
|
||||
r = await c.post(f"/v1/mcp/{pid}", json={"jsonrpc": "2.0", "id": 1, "method": "initialize"})
|
||||
assert r.status_code == 200 and r.json()["result"]["serverInfo"]["name"].startswith("forge-")
|
||||
|
||||
# tools/list exposes the project's tools
|
||||
r = await c.post(f"/v1/mcp/{pid}", json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
|
||||
names = [t["name"] for t in r.json()["result"]["tools"]]
|
||||
assert "calculator" in names
|
||||
|
||||
# tools/call runs it
|
||||
r = await c.post(f"/v1/mcp/{pid}", json={
|
||||
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
||||
"params": {"name": "calculator", "arguments": {"expression": "6*7"}},
|
||||
})
|
||||
body = r.json()["result"]
|
||||
assert body["isError"] is False and "42" in body["content"][0]["text"]
|
||||
|
||||
|
||||
async def test_mcp_exposes_project_tools():
|
||||
"""Project-level tools (workflow / knowledge / Q&A) are published on the base endpoint when
|
||||
their project.config flags are set, and never on a per-set (toolset) endpoint."""
|
||||
async with SessionLocal() as s:
|
||||
proj = Project(tenant_id="t_mcps3", name="P3", slug="p3", config={
|
||||
"mcp_expose_workflow": True, "mcp_workflow_tool_name": "run_it",
|
||||
"mcp_expose_knowledge": True, "mcp_expose_faq": True,
|
||||
})
|
||||
s.add(proj)
|
||||
await s.commit()
|
||||
await s.refresh(proj)
|
||||
pid = proj.id
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
# base endpoint: all three project tools are listed (custom workflow tool name honored)
|
||||
r = await c.post(f"/v1/mcp/{pid}", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
|
||||
names = [t["name"] for t in r.json()["result"]["tools"]]
|
||||
assert "run_it" in names
|
||||
assert "search_knowledge_base" in names
|
||||
assert "lookup_faq" in names
|
||||
|
||||
# per-toolset endpoint: project tools are a whole-project surface, so none of them appear
|
||||
r = await c.post(f"/v1/mcp/{pid}/toolset/general", json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
|
||||
names = [t["name"] for t in r.json()["result"]["tools"]]
|
||||
assert not ({"run_it", "search_knowledge_base", "lookup_faq"} & set(names))
|
||||
|
||||
|
||||
async def test_mcp_project_tools_off_by_default():
|
||||
"""No flags => no project tools (unchanged surface for existing projects)."""
|
||||
async with SessionLocal() as s:
|
||||
proj = Project(tenant_id="t_mcps4", name="P4", slug="p4", config={})
|
||||
s.add(proj)
|
||||
await s.commit()
|
||||
await s.refresh(proj)
|
||||
pid = proj.id
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
r = await c.post(f"/v1/mcp/{pid}", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
|
||||
names = [t["name"] for t in r.json()["result"]["tools"]]
|
||||
assert not ({"run_workflow", "search_knowledge_base", "lookup_faq"} & set(names))
|
||||
|
||||
|
||||
async def test_mcp_requires_key_when_configured():
|
||||
async with SessionLocal() as s:
|
||||
proj = Project(tenant_id="t_mcps2", name="P2", slug="p2", config={"mcp_api_key": "secret-key"})
|
||||
s.add(proj)
|
||||
await s.commit()
|
||||
await s.refresh(proj)
|
||||
pid = proj.id
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
# no key -> 401
|
||||
r = await c.post(f"/v1/mcp/{pid}", json={"jsonrpc": "2.0", "id": 1, "method": "initialize"})
|
||||
assert r.status_code == 401
|
||||
# correct key -> ok
|
||||
r = await c.post(f"/v1/mcp/{pid}", headers={"Authorization": "Bearer secret-key"},
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": "initialize"})
|
||||
assert r.status_code == 200
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Forge-as-an-MCP-server over the Streamable-HTTP transport.
|
||||
|
||||
The headline test drives the endpoint with the REAL `mcp` SDK client (the same protocol Claude
|
||||
Desktop / Cursor / VS Code speak), routed at the in-process ASGI app — proving a native client can
|
||||
initialize, list, and call tools with no `mcp-remote` proxy bridge. The rest pin the HTTP-level
|
||||
contract: a POST that accepts SSE gets a `text/event-stream` reply, a plain-JSON POST still gets the
|
||||
legacy JSON response, and the per-project auth applies to the streaming transport too.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.main import create_app
|
||||
from forge.models import Project, Tool
|
||||
|
||||
|
||||
async def _seed_project_with_tool(tenant="t_stream", slug="mcp-stream", config=None) -> str:
|
||||
from forge.services.tool_sets import ToolSetService
|
||||
|
||||
async with SessionLocal() as s:
|
||||
proj = Project(tenant_id=tenant, name="Stream Proj", slug=slug, config=config or {})
|
||||
s.add(proj)
|
||||
await s.flush()
|
||||
tool = Tool(tenant_id=tenant, project_id=proj.id, name="calculator", kind="builtin",
|
||||
config={"builtin": "calculator", "description": "Evaluate arithmetic."})
|
||||
s.add(tool)
|
||||
await s.commit()
|
||||
await s.refresh(proj)
|
||||
await s.refresh(tool)
|
||||
await ToolSetService.create(s, tenant, proj.id, name="General", tool_ids=[tool.id])
|
||||
return proj.id
|
||||
|
||||
|
||||
def _asgi_httpx_factory(app):
|
||||
"""An httpx client factory (the shape `streamablehttp_client` expects) that routes the MCP
|
||||
client's real HTTP traffic through the in-process ASGI app instead of the network."""
|
||||
|
||||
def make(*, headers=None, timeout=None, auth=None, **_):
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test",
|
||||
headers=headers, timeout=timeout, auth=auth,
|
||||
)
|
||||
|
||||
return make
|
||||
|
||||
|
||||
async def test_streamable_real_mcp_client_end_to_end():
|
||||
"""A real MCP SDK client initializes, lists, and calls a tool over Streamable HTTP."""
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
|
||||
pid = await _seed_project_with_tool()
|
||||
app = create_app()
|
||||
url = f"http://test/v1/mcp/{pid}"
|
||||
|
||||
async with streamablehttp_client(url, httpx_client_factory=_asgi_httpx_factory(app)) as (read, write, _sid):
|
||||
async with ClientSession(read, write) as session:
|
||||
init = await session.initialize()
|
||||
assert init.serverInfo.name.startswith("forge-")
|
||||
|
||||
tools = await session.list_tools()
|
||||
assert "calculator" in [t.name for t in tools.tools]
|
||||
|
||||
res = await session.call_tool("calculator", {"expression": "6*7"})
|
||||
assert res.isError is False
|
||||
assert "42" in res.content[0].text
|
||||
|
||||
|
||||
def _parse_sse_json(body: str) -> dict:
|
||||
"""Pull the JSON-RPC payload out of a single-message `text/event-stream` response."""
|
||||
for line in body.splitlines():
|
||||
if line.startswith("data:"):
|
||||
return json.loads(line[len("data:"):].strip())
|
||||
raise AssertionError(f"no SSE data frame in response:\n{body}")
|
||||
|
||||
|
||||
async def test_streamable_post_negotiates_sse():
|
||||
"""A POST that accepts text/event-stream is answered with an SSE-framed JSON-RPC reply."""
|
||||
pid = await _seed_project_with_tool(tenant="t_stream2", slug="mcp-stream2")
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
r = await c.post(
|
||||
f"/v1/mcp/{pid}",
|
||||
headers={"Accept": "application/json, text/event-stream"},
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
|
||||
"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"},
|
||||
}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "text/event-stream" in r.headers["content-type"]
|
||||
payload = _parse_sse_json(r.text)
|
||||
assert payload["result"]["serverInfo"]["name"].startswith("forge-")
|
||||
|
||||
|
||||
async def test_plain_json_post_still_uses_legacy_json():
|
||||
"""A POST WITHOUT an SSE Accept stays on the legacy request/response path (application/json)."""
|
||||
pid = await _seed_project_with_tool(tenant="t_stream3", slug="mcp-stream3")
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
r = await c.post(f"/v1/mcp/{pid}", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
|
||||
assert r.status_code == 200
|
||||
assert "application/json" in r.headers["content-type"]
|
||||
assert "calculator" in [t["name"] for t in r.json()["result"]["tools"]]
|
||||
|
||||
|
||||
async def test_streamable_transport_enforces_project_key():
|
||||
"""The per-project mcp_api_key gates the streaming transport, not just the legacy path."""
|
||||
pid = await _seed_project_with_tool(tenant="t_stream4", slug="mcp-stream4", config={"mcp_api_key": "sk-stream"})
|
||||
app = create_app()
|
||||
sse = {"Accept": "application/json, text/event-stream"}
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
init = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
|
||||
"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"},
|
||||
}}
|
||||
# no key -> 401 even on the streamable transport
|
||||
r = await c.post(f"/v1/mcp/{pid}", headers=sse, json=init)
|
||||
assert r.status_code == 401
|
||||
# correct key -> streamed 200
|
||||
r = await c.post(f"/v1/mcp/{pid}", headers={**sse, "Authorization": "Bearer sk-stream"}, json=init)
|
||||
assert r.status_code == 200 and "text/event-stream" in r.headers["content-type"]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Long-term memory: remember + semantic recall, and the builtin tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.services.memory import MemoryService
|
||||
from forge.services.runtime import make_runtime_ctx
|
||||
from forge.tools.materialize import materialize_tool
|
||||
|
||||
T, P = "t_mem", "p_mem"
|
||||
|
||||
|
||||
async def test_remember_then_recall():
|
||||
async with SessionLocal() as s:
|
||||
await MemoryService.remember(s, T, P, "The customer prefers email over phone.")
|
||||
await MemoryService.remember(s, T, P, "Our refund window is 30 days.")
|
||||
async with SessionLocal() as s:
|
||||
hits = await MemoryService.recall(s, T, P, "refund window days", top_k=5)
|
||||
assert any("30 days" in h for h in hits) # the refund memory is recalled
|
||||
|
||||
|
||||
async def test_scope_isolates_memories():
|
||||
async with SessionLocal() as s:
|
||||
await MemoryService.remember(s, "t_sc", "p_sc", "Alice's plan is enterprise.", scope="user:alice")
|
||||
await MemoryService.remember(s, "t_sc", "p_sc", "Bob's plan is free.", scope="user:bob")
|
||||
async with SessionLocal() as s:
|
||||
alice = await MemoryService.recall(s, "t_sc", "p_sc", "what plan", scope="user:alice", top_k=5)
|
||||
assert any("enterprise" in m for m in alice) and not any("free" in m for m in alice)
|
||||
|
||||
|
||||
async def test_memory_builtin_tools():
|
||||
ctx = make_runtime_ctx(T, P)
|
||||
remember = materialize_tool({"name": "remember", "kind": "builtin", "builtin": "remember"}, ctx)
|
||||
recall = materialize_tool({"name": "recall", "kind": "builtin", "builtin": "recall"}, ctx)
|
||||
await remember.ainvoke({"text": "The launch date is March 2027."})
|
||||
out = await recall.ainvoke({"query": "when is the launch?"})
|
||||
assert "March 2027" in out
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Model-catalog integrity.
|
||||
|
||||
CHAT_MODELS is the single source of truth for the console's model picker, and the built-in
|
||||
pricing rates derive from it. These tests enforce the invariant that prevents cost tracking
|
||||
from silently reporting $0: every model a user can pick must be priced by the backend, and the
|
||||
model a blank ("Project default") node actually runs must be selectable in the UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.engine.models import _PROVIDER_CHEAP_MODEL
|
||||
from forge.model_catalog import CHAT_MODELS, EMBEDDING_MODELS, RERANKER_MODELS, catalog_prices
|
||||
from forge.tracing.pricing import _resolve_rate, merged_prices
|
||||
|
||||
|
||||
def test_every_offered_model_is_priced():
|
||||
# A model in the dropdown with no pricing entry would cost $0 at runtime (silent under-report).
|
||||
for m in CHAT_MODELS:
|
||||
if m.id.startswith("fake"):
|
||||
continue
|
||||
assert _resolve_rate(m.id) is not None, f"{m.id} is offered but has no pricing entry"
|
||||
|
||||
|
||||
def test_cheap_defaults_are_selectable():
|
||||
# cheap_model_for_credentials picks these when a node leaves the model blank (e.g. the
|
||||
# classifier's "Project default"); each must be in the catalog so the UI can show what runs.
|
||||
ids = {m.id for m in CHAT_MODELS}
|
||||
for default in _PROVIDER_CHEAP_MODEL.values():
|
||||
assert default in ids, f"cheap default {default} is not in the model catalog"
|
||||
|
||||
|
||||
def test_no_duplicate_model_ids():
|
||||
ids = [m.id for m in CHAT_MODELS]
|
||||
assert len(ids) == len(set(ids)), "duplicate model id in CHAT_MODELS"
|
||||
|
||||
|
||||
def test_catalog_rates_are_the_ones_the_cost_engine_uses():
|
||||
# The picker's rates must be the SAME table the tracer prices with - not a divergent copy.
|
||||
prices = merged_prices()
|
||||
for bare, rate in catalog_prices().items():
|
||||
assert prices.get(bare) == rate, f"pricing for {bare} diverged from the catalog"
|
||||
|
||||
|
||||
def test_embedding_default_matches_backend():
|
||||
# The picker's default embedder must be the one the backend actually falls back to.
|
||||
from forge.knowledge.embeddings import _DEFAULT_FASTEMBED
|
||||
|
||||
defaults = [m for m in EMBEDDING_MODELS if m.default]
|
||||
assert len(defaults) == 1, "exactly one default embedding model"
|
||||
assert defaults[0].id.split(":", 1)[1] == _DEFAULT_FASTEMBED
|
||||
|
||||
|
||||
def test_reranker_default_matches_backend():
|
||||
from forge.knowledge.rerank import DEFAULT_RERANKER
|
||||
|
||||
defaults = [m for m in RERANKER_MODELS if m.default]
|
||||
assert len(defaults) == 1, "exactly one default reranker"
|
||||
assert defaults[0].id == DEFAULT_RERANKER
|
||||
|
||||
|
||||
def test_billed_embeddings_are_priced():
|
||||
# A billed embedder with no pricing entry would embed at $0 (silent cost under-report).
|
||||
for m in EMBEDDING_MODELS:
|
||||
if m.billed:
|
||||
assert _resolve_rate(m.id) is not None, f"billed embedder {m.id} has no pricing entry"
|
||||
|
||||
|
||||
def test_no_duplicate_ids_across_catalogs():
|
||||
all_ids = [m.id for m in CHAT_MODELS] + [m.id for m in EMBEDDING_MODELS] + [m.id for m in RERANKER_MODELS]
|
||||
assert len(all_ids) == len(set(all_ids)), "duplicate id across model catalogs"
|
||||
@@ -0,0 +1,127 @@
|
||||
"""3-legged OAuth (authorization_code) resolver + refresh + state-token tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from forge.auth_providers.resolver import AuthResolver
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import AuthProvider
|
||||
from forge.secrets.store import SecretStore
|
||||
from forge.security import create_state_token, decode_token
|
||||
|
||||
_CFG = {
|
||||
"kind": "oauth2_authorization_code",
|
||||
"authorize_url": "https://idp.example/authorize",
|
||||
"token_url": "https://idp.example/token",
|
||||
"client_id_ref": "secret://proj/cid",
|
||||
"client_secret_ref": "secret://proj/csec",
|
||||
}
|
||||
|
||||
|
||||
async def _store_bundle(tenant, project, ap_id, bundle):
|
||||
async with SessionLocal() as s:
|
||||
await SecretStore().write(s, tenant_id=tenant, project_id=project, name=f"oauth_token__{ap_id}", value=bundle, kind="oauth")
|
||||
|
||||
|
||||
def _ap(ap_id="ap_oauth", tenant="t_o", project="p_o"):
|
||||
return AuthProvider(id=ap_id, tenant_id=tenant, project_id=project, name="idp", kind="oauth2_authorization_code", config=_CFG)
|
||||
|
||||
|
||||
async def test_oauth_resolves_valid_bundle():
|
||||
await _store_bundle("t_o", "p_o", "ap_oauth", {"access_token": "TKN", "expires_at": time.time() + 3600})
|
||||
resolved = await AuthResolver().resolve(tenant_id="t_o", project_id="p_o", provider_id="ap_oauth", provider=_ap(), force=True)
|
||||
assert resolved.headers["Authorization"] == "Bearer TKN"
|
||||
|
||||
|
||||
async def test_oauth_refreshes_expired_token():
|
||||
async with SessionLocal() as s:
|
||||
await SecretStore().write(s, tenant_id="t_o2", project_id="p_o2", name="cid", value="client-id")
|
||||
await SecretStore().write(s, tenant_id="t_o2", project_id="p_o2", name="csec", value="client-secret")
|
||||
await _store_bundle("t_o2", "p_o2", "ap2", {"access_token": "OLD", "refresh_token": "R1", "expires_at": time.time() - 10})
|
||||
|
||||
seen = {}
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
seen["body"] = req.content.decode()
|
||||
return httpx.Response(200, json={"access_token": "NEW", "expires_in": 3600})
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
resolved = await AuthResolver().resolve(tenant_id="t_o2", project_id="p_o2", provider_id="ap2",
|
||||
provider=_ap("ap2", "t_o2", "p_o2"), client=client, force=True)
|
||||
await client.aclose()
|
||||
assert resolved.headers["Authorization"] == "Bearer NEW"
|
||||
assert "grant_type=refresh_token" in seen["body"]
|
||||
# the refreshed bundle was persisted
|
||||
new_bundle = await SecretStore().read_ref(tenant_id="t_o2", project_id="p_o2", ref="secret://proj/oauth_token__ap2")
|
||||
assert new_bundle["access_token"] == "NEW"
|
||||
|
||||
|
||||
async def test_bearer_tolerates_stale_credentials_ref():
|
||||
"""Regression: a bearer provider whose own token_ref resolves must succeed even when the
|
||||
legacy `credentials_ref` fallback points at a secret that no longer exists (the create flow
|
||||
used to copy the template default there, then it rotted when the token_ref was edited)."""
|
||||
async with SessionLocal() as s:
|
||||
await SecretStore().write(s, tenant_id="t_b", project_id="p_b", name="git_token", value="ghp_real")
|
||||
ap = AuthProvider(
|
||||
id="ap_bearer", tenant_id="t_b", project_id="p_b", name="git", kind="bearer",
|
||||
config={"kind": "bearer", "token_ref": "secret://proj/git_token", "header_name": "Authorization", "prefix": "Bearer "},
|
||||
credentials_ref="secret://proj/token", # stale - no secret named "token" exists
|
||||
)
|
||||
resolved = await AuthResolver().resolve(tenant_id="t_b", project_id="p_b", provider_id="ap_bearer", provider=ap, force=True)
|
||||
assert resolved.headers["Authorization"] == "Bearer ghp_real"
|
||||
|
||||
|
||||
async def test_secret_usage_finds_references():
|
||||
"""The pre-delete guard must surface entities that reference a secret (here, an auth provider)."""
|
||||
from forge.services.secrets import SecretService
|
||||
async with SessionLocal() as s:
|
||||
s.add(AuthProvider(
|
||||
id="ap_use", tenant_id="t_u", project_id="p_u", name="shared", kind="bearer",
|
||||
config={"kind": "bearer", "token_ref": "secret://proj/shared_key"},
|
||||
))
|
||||
await s.commit()
|
||||
refs = await SecretService.usage(s, "t_u", "p_u", name="shared_key")
|
||||
none = await SecretService.usage(s, "t_u", "p_u", name="unreferenced")
|
||||
assert any(r["type"] == "auth_provider" and r["label"] == "shared" for r in refs)
|
||||
assert none == []
|
||||
|
||||
|
||||
async def test_oauth_state_token_roundtrip():
|
||||
tok = create_state_token({"tid": "t", "pid": "p", "ap": "x"})
|
||||
claims = decode_token(tok, expected_type="oauth_state")
|
||||
assert claims["tid"] == "t" and claims["ap"] == "x"
|
||||
|
||||
|
||||
async def test_oauth_not_connected_raises():
|
||||
import pytest
|
||||
with pytest.raises((KeyError, Exception)):
|
||||
await AuthResolver().resolve(tenant_id="t_none", project_id="p_none", provider_id="apx", provider=_ap("apx", "t_none", "p_none"), force=True)
|
||||
|
||||
|
||||
async def test_per_user_connect_bundle_is_resolvable():
|
||||
"""Item 5: the connect callback now stores the bundle under the SAME per-user secret name
|
||||
that resolve/refresh read. Before the fix it wrote the default name, so a per-user provider's
|
||||
token was invisible to resolve. This exercises the connect-time name -> resolve round trip."""
|
||||
import pytest
|
||||
|
||||
cfg = {**_CFG, "per_user_context_keys": ["end_user.id"]}
|
||||
ap = AuthProvider(id="ap_pu", tenant_id="t_pu", project_id="p_pu", name="idp",
|
||||
kind="oauth2_authorization_code", config=cfg)
|
||||
ctx = {"end_user.id": "alice"}
|
||||
# The per-user name the callback computes must differ from the default single-account name.
|
||||
name = AuthResolver.bundle_secret_name("ap_pu", ctx, cfg["per_user_context_keys"])
|
||||
assert name != AuthResolver.bundle_secret_name("ap_pu")
|
||||
async with SessionLocal() as s:
|
||||
await SecretStore().write(s, tenant_id="t_pu", project_id="p_pu", name=name,
|
||||
value={"access_token": "ALICE", "expires_at": time.time() + 3600}, kind="oauth")
|
||||
# Alice's context resolves to her token...
|
||||
r = await AuthResolver().resolve(tenant_id="t_pu", project_id="p_pu", provider_id="ap_pu",
|
||||
provider=ap, context=ctx, force=True)
|
||||
assert r.headers["Authorization"] == "Bearer ALICE"
|
||||
# ...a different end-user's context does not (proving the bundle is genuinely per-user).
|
||||
with pytest.raises(KeyError):
|
||||
await AuthResolver().resolve(tenant_id="t_pu", project_id="p_pu", provider_id="ap_pu",
|
||||
provider=ap, context={"end_user.id": "bob"}, force=True)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""OpenTelemetry export emits GenAI-semconv spans from run SpanRecords."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.tracing import otel
|
||||
from forge.tracing.tracer import SpanRecord
|
||||
|
||||
|
||||
def test_export_emits_spans_with_genai_attributes():
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
|
||||
exporter = InMemorySpanExporter()
|
||||
assert otel.configure(exporter=exporter) is True
|
||||
assert otel.enabled() is True
|
||||
|
||||
records = [
|
||||
SpanRecord(id="s1", parent_id=None, name="agent_1", kind="llm", start=1000.0, end=1000.5,
|
||||
start_wall=1_700_000_000.0, end_wall=1_700_000_000.5,
|
||||
model="openai:gpt-4o-mini", input_tokens=120, output_tokens=30, cost_usd=0.0001),
|
||||
SpanRecord(id="s2", parent_id="s1", name="get_order", kind="tool", start=1000.5, end=1000.6,
|
||||
start_wall=1_700_000_000.5, end_wall=1_700_000_000.6, error="boom"),
|
||||
]
|
||||
otel.export(records, trace_name="run")
|
||||
|
||||
spans = exporter.get_finished_spans()
|
||||
# One root span ("run") now groups the child spans into a single trace with a real hierarchy.
|
||||
assert len(spans) == 3
|
||||
by_name = {s.name: s for s in spans}
|
||||
root, llm, tool = by_name["run"], by_name["agent_1"], by_name["get_order"]
|
||||
assert llm.attributes["gen_ai.request.model"] == "openai:gpt-4o-mini"
|
||||
assert llm.attributes["gen_ai.system"] == "openai"
|
||||
assert llm.attributes["gen_ai.usage.input_tokens"] == 120
|
||||
assert tool.attributes["error"] is True
|
||||
# Wall-clock start time (post-2020 in ns), NOT the ~1970 that exporting monotonic seconds gave.
|
||||
assert llm.start_time > 1_600_000_000_000_000_000
|
||||
# Single trace, correct parent/child nesting: run -> agent_1 -> get_order.
|
||||
assert root.context.trace_id == llm.context.trace_id == tool.context.trace_id
|
||||
assert root.parent is None
|
||||
assert llm.parent.span_id == root.context.span_id
|
||||
assert tool.parent.span_id == llm.context.span_id
|
||||
|
||||
|
||||
def test_export_noop_when_unconfigured():
|
||||
# reset to unconfigured state
|
||||
otel._tracer = None
|
||||
otel.export([SpanRecord(id="x", parent_id=None, name="n", kind="node", start=0.0, end=1.0)]) # must not raise
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Per-user bearer/api_key auth: a provider marked per-user (per_user_context_keys) injects EACH
|
||||
end user's OWN self-served token, with no shared secret and no passthrough of the inbound token.
|
||||
Companion to test_connected_credentials.py (which covers the oauth2_authorization_code path)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from forge.auth_providers.resolver import AuthResolver
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.main import create_app
|
||||
from forge.services.auth_providers import AuthProviderService
|
||||
|
||||
|
||||
async def _provider(tenant: str, project: str, kind: str, extra: dict) -> str:
|
||||
async with SessionLocal() as s:
|
||||
ap = await AuthProviderService.create(
|
||||
s, tenant, project, name="api", kind=kind,
|
||||
config={"per_user_context_keys": ["end_user_id"], **extra},
|
||||
)
|
||||
return ap.id
|
||||
|
||||
|
||||
async def test_per_user_bearer_resolves_each_users_own_token():
|
||||
tenant, project = "t_pu_b", "p_pu_b"
|
||||
ap_id = await _provider(tenant, project, "bearer", {"header_name": "Authorization", "prefix": "Bearer "})
|
||||
async with SessionLocal() as s:
|
||||
ap = await AuthProviderService.get(s, tenant, ap_id)
|
||||
await AuthProviderService.set_user_connection(s, tenant, project, ap, "user-A", bundle={"access_token": "PAT-A"})
|
||||
await AuthProviderService.set_user_connection(s, tenant, project, ap, "user-B", bundle={"access_token": "PAT-B"})
|
||||
|
||||
resolver = AuthResolver()
|
||||
ra = await resolver.resolve(tenant_id=tenant, project_id=project, provider_id=ap_id, context={"end_user_id": "user-A"})
|
||||
assert ra.headers["Authorization"] == "Bearer PAT-A"
|
||||
rb = await resolver.resolve(tenant_id=tenant, project_id=project, provider_id=ap_id, context={"end_user_id": "user-B"})
|
||||
assert rb.headers["Authorization"] == "Bearer PAT-B"
|
||||
|
||||
# A user who hasn't connected their own token yet gets a clear "not connected" error, never
|
||||
# a silent miss or another user's token.
|
||||
with pytest.raises(KeyError):
|
||||
await resolver.resolve(tenant_id=tenant, project_id=project, provider_id=ap_id, context={"end_user_id": "user-C"})
|
||||
|
||||
|
||||
async def test_per_user_api_key_resolves_each_users_own_value():
|
||||
tenant, project = "t_pu_k", "p_pu_k"
|
||||
ap_id = await _provider(tenant, project, "api_key", {"in": "header", "name": "X-API-Key"})
|
||||
async with SessionLocal() as s:
|
||||
ap = await AuthProviderService.get(s, tenant, ap_id)
|
||||
await AuthProviderService.set_user_connection(s, tenant, project, ap, "u1", bundle={"access_token": "KEY-1"})
|
||||
|
||||
resolver = AuthResolver()
|
||||
ra = await resolver.resolve(tenant_id=tenant, project_id=project, provider_id=ap_id, context={"end_user_id": "u1"})
|
||||
assert ra.headers["X-API-Key"] == "KEY-1"
|
||||
with pytest.raises(KeyError):
|
||||
await resolver.resolve(tenant_id=tenant, project_id=project, provider_id=ap_id, context={"end_user_id": "u2"})
|
||||
|
||||
|
||||
async def test_per_user_bearer_inline_token_wins_and_no_cache_collision():
|
||||
"""A per-user provider with token_ctx_key forwards an INLINE token from the run context (the
|
||||
web-chat /run path) ahead of the stored connection, and different inline tokens sharing the same
|
||||
end_user dims must not collide in the resolver cache."""
|
||||
tenant, project = "t_pu_i", "p_pu_i"
|
||||
ap_id = await _provider(tenant, project, "bearer",
|
||||
{"header_name": "Authorization", "prefix": "Bearer ", "token_ctx_key": "user_pat"})
|
||||
resolver = AuthResolver()
|
||||
ra = await resolver.resolve(tenant_id=tenant, project_id=project, provider_id=ap_id,
|
||||
context={"end_user_id": "anon", "user_pat": "INLINE-A"})
|
||||
assert ra.headers["Authorization"] == "Bearer INLINE-A"
|
||||
# Different inline token, same end_user dims -> must NOT be served the cached "INLINE-A".
|
||||
rb = await resolver.resolve(tenant_id=tenant, project_id=project, provider_id=ap_id,
|
||||
context={"end_user_id": "anon", "user_pat": "INLINE-B"})
|
||||
assert rb.headers["Authorization"] == "Bearer INLINE-B"
|
||||
# No inline token -> falls back to the stored per-user connection (MCP/console path).
|
||||
async with SessionLocal() as s:
|
||||
ap = await AuthProviderService.get(s, tenant, ap_id)
|
||||
await AuthProviderService.set_user_connection(s, tenant, project, ap, "stored-user", bundle={"access_token": "STORED"})
|
||||
rc = await resolver.resolve(tenant_id=tenant, project_id=project, provider_id=ap_id,
|
||||
context={"end_user_id": "stored-user"})
|
||||
assert rc.headers["Authorization"] == "Bearer STORED"
|
||||
|
||||
|
||||
async def test_extra_headers_literal_and_secret_ref():
|
||||
"""A provider's extra_headers are stamped on every call: literals verbatim, secret:// refs
|
||||
resolved from the secret store — so a shared service token lives in the store, never hardcoded."""
|
||||
from forge.secrets.store import SecretStore
|
||||
tenant, project = "t_eh", "p_eh"
|
||||
async with SessionLocal() as s:
|
||||
await SecretStore().write(s, tenant_id=tenant, project_id=project, name="primary", value="PRIMARY")
|
||||
await SecretStore().write(s, tenant_id=tenant, project_id=project, name="svc_tok", value="SVC-123")
|
||||
ap = await AuthProviderService.create(
|
||||
s, tenant, project, name="q", kind="bearer",
|
||||
config={"kind": "bearer", "token_ref": "secret://proj/primary", "header_name": "Authorization",
|
||||
"prefix": "Bearer ", "extra_headers": {"X-Forge-Client-Id": "forge",
|
||||
"X-Forge-Service-Token": "secret://proj/svc_tok"}},
|
||||
)
|
||||
ap_id = ap.id
|
||||
|
||||
r = await AuthResolver().resolve(tenant_id=tenant, project_id=project, provider_id=ap_id)
|
||||
assert r.headers["Authorization"] == "Bearer PRIMARY"
|
||||
assert r.headers["X-Forge-Client-Id"] == "forge" # literal, verbatim
|
||||
assert r.headers["X-Forge-Service-Token"] == "SVC-123" # resolved from the secret store
|
||||
|
||||
|
||||
def _http() -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app()), base_url="http://test")
|
||||
|
||||
|
||||
async def test_connections_router_self_service_end_to_end():
|
||||
"""The connector-safe /connections router is served (not the /auth-providers admin surface) and
|
||||
a logged-in user can discover per-user providers + set/read/clear THEIR OWN token. Role is not
|
||||
gated here, so a connector uses the identical path; resolver injection keyed by end_user_id is
|
||||
covered by the unit tests above."""
|
||||
async with _http() as c:
|
||||
reg = (await c.post("/v1/auth/register", json={"email": f"pu{uuid.uuid4().hex[:8]}@ex.com", "password": "supersecret1"})).json()
|
||||
h = {"Authorization": f"Bearer {reg['access_token']}"}
|
||||
pid = (await c.post("/v1/projects", json={"name": "PU"}, headers=h)).json()["id"]
|
||||
|
||||
# Owner creates a PER-USER bearer provider (no shared secret; each user brings their own).
|
||||
ap = (await c.post(f"/v1/projects/{pid}/auth-providers", json={
|
||||
"name": "api", "kind": "bearer",
|
||||
"config": {"kind": "bearer", "header_name": "Authorization", "prefix": "Bearer ",
|
||||
"per_user_context_keys": ["end_user_id"]},
|
||||
}, headers=h)).json()
|
||||
ap_id = ap["id"]
|
||||
|
||||
# Discovery lists it as not-yet-connected for the caller.
|
||||
lst = (await c.get(f"/v1/projects/{pid}/connections", headers=h)).json()
|
||||
assert [x for x in lst if x["id"] == ap_id and x["connected"] is False], lst
|
||||
|
||||
# Set my own token -> 204, reads back connected.
|
||||
r = await c.put(f"/v1/projects/{pid}/connections/{ap_id}", json={"access_token": "MY-TOKEN"}, headers=h)
|
||||
assert r.status_code == 204, r.text
|
||||
assert (await c.get(f"/v1/projects/{pid}/connections/{ap_id}", headers=h)).json()["connected"] is True
|
||||
lst2 = (await c.get(f"/v1/projects/{pid}/connections", headers=h)).json()
|
||||
assert [x for x in lst2 if x["id"] == ap_id and x["connected"] is True]
|
||||
|
||||
# The provider's own /test now resolves the CALLER's connected token: console tests run AS
|
||||
# the current user, so a per-user provider no longer reports "not connected" for the tester.
|
||||
t = (await c.post(f"/v1/projects/{pid}/auth-providers/{ap_id}/test", json={}, headers=h)).json()
|
||||
assert t.get("ok") is True, t
|
||||
assert "Authorization" in (t.get("headers") or {}), t
|
||||
|
||||
# Clear -> not connected again.
|
||||
assert (await c.delete(f"/v1/projects/{pid}/connections/{ap_id}", headers=h)).status_code == 204
|
||||
assert (await c.get(f"/v1/projects/{pid}/connections/{ap_id}", headers=h)).json()["connected"] is False
|
||||
|
||||
|
||||
async def test_connections_rejects_non_per_user_provider():
|
||||
"""A shared (non-per-user) provider can't take a per-user token via /connections."""
|
||||
async with _http() as c:
|
||||
reg = (await c.post("/v1/auth/register", json={"email": f"pu{uuid.uuid4().hex[:8]}@ex.com", "password": "supersecret1"})).json()
|
||||
h = {"Authorization": f"Bearer {reg['access_token']}"}
|
||||
pid = (await c.post("/v1/projects", json={"name": "PU2"}, headers=h)).json()["id"]
|
||||
ap = (await c.post(f"/v1/projects/{pid}/auth-providers", json={
|
||||
"name": "shared", "kind": "bearer",
|
||||
"config": {"kind": "bearer", "token_ref": "secret://proj/token"},
|
||||
}, headers=h)).json()
|
||||
r = await c.put(f"/v1/projects/{pid}/connections/{ap['id']}", json={"access_token": "X"}, headers=h)
|
||||
assert r.status_code == 400, r.text
|
||||
# And it never appears in the connector's per-user discovery list.
|
||||
assert all(x["id"] != ap["id"] for x in (await c.get(f"/v1/projects/{pid}/connections", headers=h)).json())
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Unit tests for the pgvector backend's selection + Chroma-where translation.
|
||||
|
||||
No live Postgres here: these cover the pure, bug-prone logic (Chroma where-dict -> SQL
|
||||
predicate, vector literal encoding, sync-DSN derivation) and the make_store() backend switch.
|
||||
The dense/hybrid search SQL shares its fusion + where semantics with the Chroma path that the
|
||||
other knowledge tests already exercise end to end.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from forge.config import settings
|
||||
from forge.knowledge import store as store_mod
|
||||
from forge.knowledge.pgvector_store import (
|
||||
PgVectorStore,
|
||||
_sync_dsn,
|
||||
_to_vector_literal,
|
||||
_translate_where,
|
||||
)
|
||||
from forge.knowledge.store import make_store
|
||||
|
||||
|
||||
def test_translate_where_and_eq():
|
||||
params: list = []
|
||||
sql = _translate_where({"$and": [{"tenant_id": {"$eq": "t1"}}, {"project_id": {"$eq": "p1"}}]}, params)
|
||||
assert "metadata->>%s" in sql and " AND " in sql
|
||||
# field names AND values are bound positionally, never string-formatted
|
||||
assert params == ["tenant_id", "t1", "project_id", "p1"]
|
||||
|
||||
|
||||
def test_translate_where_in_and_shorthand():
|
||||
params: list = []
|
||||
sql = _translate_where({"kind": {"$in": ["faq", "hours"]}}, params)
|
||||
assert "= ANY(%s)" in sql
|
||||
assert params == ["kind", ["faq", "hours"]]
|
||||
# {field: value} shorthand behaves as $eq
|
||||
params2: list = []
|
||||
_translate_where({"source_id": "s1"}, params2)
|
||||
assert params2 == ["source_id", "s1"]
|
||||
|
||||
|
||||
def test_translate_where_empty_is_true():
|
||||
assert _translate_where(None, []) == "TRUE"
|
||||
assert _translate_where({}, []) == "TRUE"
|
||||
|
||||
|
||||
def test_translate_where_rejects_unknown_operator():
|
||||
# fail closed: an operator we don't model must never silently drop the filter
|
||||
with pytest.raises(ValueError):
|
||||
_translate_where({"score": {"$gt": 0.5}}, [])
|
||||
|
||||
|
||||
def test_vector_literal_encoding():
|
||||
assert _to_vector_literal([0.0, 1.5, -2.0]) == "[0.0,1.5,-2.0]"
|
||||
|
||||
|
||||
def test_sync_dsn_strips_async_driver(monkeypatch):
|
||||
monkeypatch.setattr(settings, "database_url", "postgresql+asyncpg://u:p@h:5432/db", raising=False)
|
||||
assert _sync_dsn() == "postgresql://u:p@h:5432/db"
|
||||
|
||||
|
||||
def test_sync_dsn_rejects_sqlite(monkeypatch):
|
||||
monkeypatch.setattr(settings, "database_url", "sqlite+aiosqlite:///x.db", raising=False)
|
||||
with pytest.raises(RuntimeError):
|
||||
_sync_dsn()
|
||||
|
||||
|
||||
def test_make_store_defaults_to_chroma(monkeypatch):
|
||||
# default backend selects Chroma; stub the ctor so no real on-disk client is built
|
||||
monkeypatch.setattr(settings, "vector_backend", "chroma", raising=False)
|
||||
marker = object()
|
||||
monkeypatch.setattr(store_mod, "ChromaStore", lambda collection="forge_kb": marker)
|
||||
assert make_store(collection="forge_kb_8") is marker
|
||||
|
||||
|
||||
def test_make_store_selects_pgvector(monkeypatch):
|
||||
monkeypatch.setattr(settings, "vector_backend", "pgvector", raising=False)
|
||||
monkeypatch.setattr(settings, "database_url", "postgresql+asyncpg://u:p@h/db", raising=False)
|
||||
monkeypatch.setattr(PgVectorStore, "_ensure_schema", lambda self: None) # skip the DB bootstrap
|
||||
st = make_store(collection="forge_kb_16")
|
||||
assert isinstance(st, PgVectorStore)
|
||||
assert st._collection == "forge_kb_16"
|
||||
assert st._dsn == "postgresql://u:p@h/db"
|
||||
@@ -0,0 +1,420 @@
|
||||
"""Platform-hardening tests (findings a-k): auth lifecycle, RBAC/API-keys, rate limiting,
|
||||
audit pagination/export, project budgets, retention, OAuth PKCE, and ops guards.
|
||||
|
||||
In-process ASGI. Each test resets the shared in-process rate limiter + revocation state so the
|
||||
process-wide singletons can't leak between tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from forge.config import settings
|
||||
from forge.main import create_app
|
||||
|
||||
|
||||
def _client() -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app()), base_url="http://test")
|
||||
|
||||
|
||||
def _email() -> str:
|
||||
return f"u{uuid.uuid4().hex[:10]}@example.com"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_platform_state():
|
||||
from forge.security import _revocations
|
||||
from forge.util.ratelimit import rate_limiter
|
||||
|
||||
try:
|
||||
rate_limiter._local._buckets.clear()
|
||||
except Exception:
|
||||
pass
|
||||
_revocations._jti.clear()
|
||||
_revocations._user_cut.clear()
|
||||
yield
|
||||
|
||||
|
||||
# --- c: production hardening guard ------------------------------------------------------
|
||||
|
||||
def test_production_guard_flags_new_gaps():
|
||||
from forge.config import Settings
|
||||
|
||||
s = Settings()
|
||||
s.environment = "production"
|
||||
s.jwt_secret = "x" * 40
|
||||
s.auth_required = True
|
||||
s.bootstrap_admin_password = "a-strong-password"
|
||||
s.egress_block_private = True
|
||||
s.database_url = "postgresql+asyncpg://u:p@db/forge"
|
||||
s.checkpoint_backend = "postgres"
|
||||
s.trusted_hosts = [] # -> flagged
|
||||
s.public_base_url = "http://forge.example.com" # http -> flagged
|
||||
s.public_console_url = "https://app.example.com"
|
||||
s.service_api_token = "tooshort" # < min length -> flagged
|
||||
problems = s.validate_production()
|
||||
assert any("TRUSTED_HOSTS" in p for p in problems)
|
||||
assert any("PUBLIC_BASE_URL" in p for p in problems)
|
||||
assert any("SERVICE_API_TOKEN" in p for p in problems)
|
||||
|
||||
# Fixing them clears exactly those problems.
|
||||
s.trusted_hosts = ["forge.example.com"]
|
||||
s.public_base_url = "https://forge.example.com"
|
||||
s.service_api_token = "" # empty = disabled, allowed
|
||||
cleared = s.validate_production()
|
||||
assert not any(("TRUSTED_HOSTS" in p or "PUBLIC_BASE_URL" in p or "SERVICE_API_TOKEN" in p) for p in cleared)
|
||||
|
||||
|
||||
def test_multi_worker_without_redis_warns():
|
||||
from forge.config import Settings
|
||||
|
||||
s = Settings()
|
||||
s.web_concurrency = 4
|
||||
s.redis_url = None
|
||||
assert any("Multiple workers" in w for w in s.startup_warnings())
|
||||
|
||||
|
||||
# --- b: rate limiter pruning + fail-closed public surface -------------------------------
|
||||
|
||||
def test_inprocess_bucket_prunes_idle_keys():
|
||||
import forge.util.ratelimit as rl
|
||||
|
||||
limiter = rl.RateLimiter()
|
||||
limiter.allow("old", rate=100)
|
||||
limiter._buckets["old"].updated -= (rl._BUCKET_IDLE_TTL + 10) # look idle
|
||||
limiter._last_prune -= (rl._BUCKET_PRUNE_EVERY + 10) # allow a sweep
|
||||
limiter.allow("new", rate=100) # triggers prune
|
||||
assert "old" not in limiter._buckets and "new" in limiter._buckets
|
||||
|
||||
|
||||
def test_public_surface_fails_closed_when_redis_unavailable():
|
||||
from forge.util.ratelimit import ResilientRateLimiter, _RedisConn
|
||||
|
||||
class _Down(_RedisConn):
|
||||
def __init__(self):
|
||||
super().__init__("redis://unreachable")
|
||||
|
||||
def get(self):
|
||||
return None # configured but never connects
|
||||
|
||||
limiter = ResilientRateLimiter(_Down())
|
||||
assert limiter.allow("embed:key", rate=5) is False # public -> DENY (fail closed)
|
||||
assert limiter.allow("runs:tenant", rate=5) is True # non-public -> in-process fallback
|
||||
|
||||
|
||||
def test_public_surface_fails_closed_on_redis_error():
|
||||
from forge.util.ratelimit import ResilientRateLimiter, _RedisConn
|
||||
|
||||
class _Broken:
|
||||
def pipeline(self):
|
||||
raise RuntimeError("redis down")
|
||||
|
||||
class _Conn(_RedisConn):
|
||||
def __init__(self):
|
||||
super().__init__("redis://x")
|
||||
self._client = _Broken()
|
||||
|
||||
def get(self):
|
||||
return self._client
|
||||
|
||||
limiter = ResilientRateLimiter(_Conn())
|
||||
assert limiter.allow("embed:abc", rate=5) is False
|
||||
|
||||
|
||||
# --- f: project budgets + allowed-models ------------------------------------------------
|
||||
|
||||
async def test_project_budget_and_allowed_models():
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import Project, Run
|
||||
from forge.services.budget import BudgetExceeded, ModelNotAllowed, enforce_project_budget
|
||||
|
||||
async with SessionLocal() as s:
|
||||
p = Project(tenant_id="tb", name="B", slug="b", config={
|
||||
"allowed_models": ["openai:gpt-4o"],
|
||||
"budgets": {"monthly_usd_cap": 1.0, "max_usd_per_run": 0.0},
|
||||
})
|
||||
s.add(p)
|
||||
await s.commit()
|
||||
pid = p.id
|
||||
|
||||
async with SessionLocal() as s:
|
||||
with pytest.raises(ModelNotAllowed):
|
||||
await enforce_project_budget(s, "tb", pid, model="anthropic:claude")
|
||||
await enforce_project_budget(s, "tb", pid, model="openai:gpt-4o") # allowed, no spend yet
|
||||
|
||||
async with SessionLocal() as s:
|
||||
s.add(Run(tenant_id="tb", project_id=pid, workflow_id="w", thread_id="t",
|
||||
status="done", total_cost_usd=1.5))
|
||||
await s.commit()
|
||||
async with SessionLocal() as s:
|
||||
with pytest.raises(BudgetExceeded):
|
||||
await enforce_project_budget(s, "tb", pid, model="openai:gpt-4o")
|
||||
|
||||
|
||||
def test_disallowed_workflow_models_at_publish():
|
||||
"""Per-node allowed_models validation (item 6): every chat model in a workflow's nodes is
|
||||
checked at publish, mirroring the admission-time single-model check across all nodes."""
|
||||
from forge.services.budget import collect_workflow_models, disallowed_workflow_models
|
||||
|
||||
executable = {
|
||||
"nodes": [
|
||||
{"id": "a", "type": "agent", "config": {"model": "openai:gpt-4o", "middleware": [
|
||||
{"kind": "model_fallback", "config": {"models": ["anthropic:claude", "openai:gpt-4o"]}},
|
||||
]}},
|
||||
{"id": "l", "type": "llm", "config": {"model": "openai:gpt-4o"}},
|
||||
{"id": "r", "type": "retrieval", "config": {"embedding_model": "fastembed:bge"}},
|
||||
{"id": "e", "type": "end", "config": {}},
|
||||
]
|
||||
}
|
||||
# agent/llm/classifier + nested middleware models are collected; the embedder is NOT.
|
||||
assert collect_workflow_models(executable) == {"openai:gpt-4o", "anthropic:claude"}
|
||||
# no allow-list => no-op (publish always allowed)
|
||||
assert disallowed_workflow_models({}, executable) == []
|
||||
# allow-list forbids the fallback's anthropic model
|
||||
assert disallowed_workflow_models({"allowed_models": ["openai:gpt-4o"]}, executable) == ["anthropic:claude"]
|
||||
# a fully-covered allow-list passes
|
||||
assert disallowed_workflow_models({"allowed_models": ["openai:gpt-4o", "anthropic:claude"]}, executable) == []
|
||||
|
||||
|
||||
# --- a: auth endpoint throttling --------------------------------------------------------
|
||||
|
||||
async def test_login_is_throttled_per_email(monkeypatch):
|
||||
monkeypatch.setattr(settings, "auth_rate_limit_per_minute", 3)
|
||||
async with _client() as c:
|
||||
email = _email()
|
||||
await c.post("/v1/auth/register", json={"email": email, "password": "supersecret1"})
|
||||
codes = [
|
||||
(await c.post("/v1/auth/login", json={"email": email, "password": "wrong"})).status_code
|
||||
for _ in range(6)
|
||||
]
|
||||
assert 429 in codes
|
||||
|
||||
|
||||
# --- d: refresh rotation, reuse detection, logout-all -----------------------------------
|
||||
|
||||
async def test_refresh_rotates_and_detects_reuse():
|
||||
async with _client() as c:
|
||||
reg = (await c.post("/v1/auth/register", json={"email": _email(), "password": "supersecret1"})).json()
|
||||
old_rt = reg["refresh_token"]
|
||||
r1 = await c.post("/v1/auth/refresh", json={"refresh_token": old_rt})
|
||||
assert r1.status_code == 200
|
||||
new_rt = r1.json()["refresh_token"]
|
||||
assert new_rt != old_rt
|
||||
# Reusing the rotated (old) token is detected -> 401 and the whole family is revoked.
|
||||
assert (await c.post("/v1/auth/refresh", json={"refresh_token": old_rt})).status_code == 401
|
||||
assert (await c.post("/v1/auth/refresh", json={"refresh_token": new_rt})).status_code == 401
|
||||
|
||||
|
||||
async def test_logout_all_invalidates_existing_access_token():
|
||||
async with _client() as c:
|
||||
reg = (await c.post("/v1/auth/register", json={"email": _email(), "password": "supersecret1"})).json()
|
||||
h = {"Authorization": f"Bearer {reg['access_token']}"}
|
||||
assert (await c.get("/v1/auth/me", headers=h)).status_code == 200
|
||||
assert (await c.post("/v1/auth/logout-all", headers=h)).status_code == 200
|
||||
assert (await c.get("/v1/auth/me", headers=h)).status_code == 401
|
||||
|
||||
|
||||
async def test_logout_revokes_refresh_token():
|
||||
async with _client() as c:
|
||||
reg = (await c.post("/v1/auth/register", json={"email": _email(), "password": "supersecret1"})).json()
|
||||
rt = reg["refresh_token"]
|
||||
assert (await c.post("/v1/auth/logout", json={"refresh_token": rt})).status_code == 200
|
||||
assert (await c.post("/v1/auth/refresh", json={"refresh_token": rt})).status_code == 401
|
||||
|
||||
|
||||
# --- h: API keys + per-project RBAC -----------------------------------------------------
|
||||
|
||||
async def test_api_key_lifecycle():
|
||||
async with _client() as c:
|
||||
reg = (await c.post("/v1/auth/register", json={"email": _email(), "password": "supersecret1"})).json()
|
||||
h = {"Authorization": f"Bearer {reg['access_token']}"}
|
||||
created = await c.post("/v1/api-keys", json={"name": "ci", "role": "editor"}, headers=h)
|
||||
assert created.status_code == 201, created.text
|
||||
key, key_id = created.json()["key"], created.json()["id"]
|
||||
assert key.startswith("forge_sk_")
|
||||
|
||||
kh = {"Authorization": f"Bearer {key}"}
|
||||
assert (await c.get("/v1/projects", headers=kh)).status_code == 200
|
||||
me = await c.get("/v1/auth/me", headers=kh)
|
||||
assert me.status_code == 200 and me.json()["role"] == "editor"
|
||||
|
||||
assert (await c.delete(f"/v1/api-keys/{key_id}", headers=h)).status_code == 204
|
||||
assert (await c.get("/v1/projects", headers=kh)).status_code == 401
|
||||
|
||||
|
||||
async def test_api_key_cannot_exceed_creator_role():
|
||||
async with _client() as c:
|
||||
# invite an editor, then that editor tries to mint an owner key
|
||||
owner = (await c.post("/v1/auth/register", json={"email": _email(), "password": "ownerpass1"})).json()
|
||||
oh = {"Authorization": f"Bearer {owner['access_token']}"}
|
||||
ed_email = _email()
|
||||
await c.post("/v1/team/members", json={"email": ed_email, "role": "admin", "password": "adminpass1"}, headers=oh)
|
||||
ed = (await c.post("/v1/auth/login", json={"email": ed_email, "password": "adminpass1"})).json()
|
||||
eh = {"Authorization": f"Bearer {ed['access_token']}"}
|
||||
assert (await c.post("/v1/api-keys", json={"name": "x", "role": "owner"}, headers=eh)).status_code == 403
|
||||
|
||||
|
||||
async def test_per_project_membership_elevates_role():
|
||||
async with _client() as c:
|
||||
owner = (await c.post("/v1/auth/register", json={"email": _email(), "password": "ownerpass1"})).json()
|
||||
oh = {"Authorization": f"Bearer {owner['access_token']}"}
|
||||
member_email = _email()
|
||||
inv = await c.post("/v1/team/members",
|
||||
json={"email": member_email, "role": "viewer", "password": "viewerpass1"}, headers=oh)
|
||||
member_id = inv.json()["id"]
|
||||
member = (await c.post("/v1/auth/login", json={"email": member_email, "password": "viewerpass1"})).json()
|
||||
mh = {"Authorization": f"Bearer {member['access_token']}"}
|
||||
|
||||
pid = (await c.post("/v1/projects", json={"name": "P"}, headers=oh)).json()["id"]
|
||||
# Global viewer can't PATCH (admin-gated) the project...
|
||||
assert (await c.patch(f"/v1/projects/{pid}", json={"name": "X"}, headers=mh)).status_code == 403
|
||||
# ...until granted admin ON THIS PROJECT.
|
||||
assert (await c.put(f"/v1/projects/{pid}/members/{member_id}", json={"role": "admin"}, headers=oh)).status_code == 200
|
||||
assert (await c.patch(f"/v1/projects/{pid}", json={"name": "Y"}, headers=mh)).status_code == 200
|
||||
|
||||
|
||||
# --- g: audit pagination, filters, export ----------------------------------------------
|
||||
|
||||
async def test_audit_pagination_filter_and_export():
|
||||
async with _client() as c:
|
||||
reg = (await c.post("/v1/auth/register", json={"email": _email(), "password": "supersecret1"})).json()
|
||||
h = {"Authorization": f"Bearer {reg['access_token']}"}
|
||||
for i in range(3):
|
||||
await c.post("/v1/projects", json={"name": f"P{i}"}, headers=h)
|
||||
|
||||
p1 = await c.get("/v1/audit?limit=2", headers=h)
|
||||
assert p1.status_code == 200 and len(p1.json()) == 2
|
||||
cursor = p1.headers.get("X-Next-Cursor")
|
||||
assert cursor
|
||||
p2 = await c.get(f"/v1/audit?limit=2&cursor={cursor}", headers=h)
|
||||
assert p2.status_code == 200 and len(p2.json()) >= 1
|
||||
assert {a["id"] for a in p1.json()}.isdisjoint({a["id"] for a in p2.json()})
|
||||
|
||||
filtered = await c.get("/v1/audit", params={"action": "POST /v1/projects"}, headers=h)
|
||||
rows = filtered.json()
|
||||
assert len(rows) >= 3 and all(a["action"] == "POST /v1/projects" for a in rows)
|
||||
|
||||
export = await c.get("/v1/audit/export", headers=h)
|
||||
assert export.status_code == 200
|
||||
lines = [ln for ln in export.text.splitlines() if ln.strip()]
|
||||
assert len(lines) >= 3 and all("action" in _json.loads(ln) for ln in lines)
|
||||
|
||||
|
||||
# --- j: password reset, email verification, TOTP MFA -----------------------------------
|
||||
|
||||
async def test_password_reset_flow():
|
||||
async with _client() as c:
|
||||
email = _email()
|
||||
await c.post("/v1/auth/register", json={"email": email, "password": "origpass1"})
|
||||
rr = await c.post("/v1/auth/request-password-reset", json={"email": email})
|
||||
assert rr.status_code == 200
|
||||
url = rr.json().get("reset_url") # no SMTP in tests -> link returned
|
||||
assert url and "reset=" in url
|
||||
token = url.split("reset=", 1)[1]
|
||||
assert (await c.post("/v1/auth/reset-password", json={"token": token, "password": "newpass123"})).status_code == 200
|
||||
assert (await c.post("/v1/auth/login", json={"email": email, "password": "origpass1"})).status_code == 401
|
||||
assert (await c.post("/v1/auth/login", json={"email": email, "password": "newpass123"})).status_code == 200
|
||||
|
||||
|
||||
async def test_email_verification_flow():
|
||||
async with _client() as c:
|
||||
reg = (await c.post("/v1/auth/register", json={"email": _email(), "password": "supersecret1"})).json()
|
||||
h = {"Authorization": f"Bearer {reg['access_token']}"}
|
||||
rv = await c.post("/v1/auth/request-email-verification", headers=h)
|
||||
assert rv.status_code == 200
|
||||
url = rv.json().get("verify_url")
|
||||
assert url and "verify_email=" in url
|
||||
token = url.split("verify_email=", 1)[1]
|
||||
assert (await c.post("/v1/auth/verify-email", json={"token": token})).status_code == 200
|
||||
|
||||
|
||||
async def test_totp_enroll_confirm_and_enforced_at_login():
|
||||
from forge.security import _totp_at
|
||||
|
||||
async with _client() as c:
|
||||
email = _email()
|
||||
reg = (await c.post("/v1/auth/register", json={"email": email, "password": "supersecret1"})).json()
|
||||
h = {"Authorization": f"Bearer {reg['access_token']}"}
|
||||
secret = (await c.post("/v1/auth/mfa/totp/enroll", headers=h)).json()["secret"]
|
||||
code = _totp_at(secret, int(time.time() // 30))
|
||||
cf = await c.post("/v1/auth/mfa/totp/confirm", json={"code": code}, headers=h)
|
||||
assert cf.status_code == 200 and cf.json()["mfa_enabled"] is True
|
||||
# login now requires a valid code
|
||||
assert (await c.post("/v1/auth/login", json={"email": email, "password": "supersecret1"})).status_code == 401
|
||||
ok = await c.post("/v1/auth/login", json={
|
||||
"email": email, "password": "supersecret1", "totp_code": _totp_at(secret, int(time.time() // 30))})
|
||||
assert ok.status_code == 200
|
||||
|
||||
|
||||
# --- k: workspace admin + readiness -----------------------------------------------------
|
||||
|
||||
async def test_workspace_get_and_update():
|
||||
async with _client() as c:
|
||||
reg = (await c.post("/v1/auth/register", json={"email": _email(), "password": "supersecret1"})).json()
|
||||
h = {"Authorization": f"Bearer {reg['access_token']}"}
|
||||
assert (await c.get("/v1/workspace", headers=h)).status_code == 200
|
||||
u = await c.patch("/v1/workspace", json={"name": "Renamed WS", "settings": {"max_runs_per_day": 5}}, headers=h)
|
||||
assert u.status_code == 200
|
||||
body = u.json()
|
||||
assert body["name"] == "Renamed WS" and body["settings"]["max_runs_per_day"] == 5
|
||||
|
||||
|
||||
async def test_readyz_reports_dependency_checks():
|
||||
async with _client() as c:
|
||||
body = (await c.get("/readyz")).json()
|
||||
assert "checks" in body
|
||||
assert "db" in body["checks"] and "checkpointer" in body["checks"] and "vector_store" in body["checks"]
|
||||
|
||||
|
||||
async def test_global_rate_limit_middleware(monkeypatch):
|
||||
monkeypatch.setattr(settings, "api_rate_limit_per_minute", 3)
|
||||
async with _client() as c:
|
||||
reg = (await c.post("/v1/auth/register", json={"email": _email(), "password": "supersecret1"})).json()
|
||||
h = {"Authorization": f"Bearer {reg['access_token']}"}
|
||||
from forge.util.ratelimit import rate_limiter
|
||||
rate_limiter._local._buckets.clear() # isolate the GET burst from the register POST
|
||||
codes = [(await c.get("/v1/auth/me", headers=h)).status_code for _ in range(6)]
|
||||
assert codes.count(200) == 3 and 429 in codes # burst of 3 (rate), then throttled
|
||||
|
||||
|
||||
# --- e: scheduled retention purge -------------------------------------------------------
|
||||
|
||||
async def test_retention_purges_past_horizon():
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import Project, Run, Span, Trace
|
||||
from forge.services.retention import RetentionService
|
||||
|
||||
async with SessionLocal() as s:
|
||||
p = Project(tenant_id="tret", name="R", slug="r", config={"tracing": {"retention_days": 7}})
|
||||
s.add(p)
|
||||
await s.flush()
|
||||
pid = p.id
|
||||
old = datetime.utcnow() - timedelta(days=30)
|
||||
tr = Trace(tenant_id="tret", project_id=pid, run_id="r1", name="t", status="done")
|
||||
old_run = Run(tenant_id="tret", project_id=pid, workflow_id="w", thread_id="th", status="done")
|
||||
recent_run = Run(tenant_id="tret", project_id=pid, workflow_id="w", thread_id="th2", status="done")
|
||||
s.add_all([tr, old_run, recent_run])
|
||||
await s.flush()
|
||||
sp = Span(tenant_id="tret", trace_id=tr.id, name="s", kind="node")
|
||||
s.add(sp)
|
||||
await s.flush()
|
||||
tr.created_at = old
|
||||
old_run.created_at = old
|
||||
await s.commit()
|
||||
trace_id, span_id, old_run_id, recent_run_id = tr.id, sp.id, old_run.id, recent_run.id
|
||||
|
||||
counts = await RetentionService.purge_expired()
|
||||
assert counts["traces"] >= 1 and counts["runs"] >= 1 and counts["spans"] >= 1
|
||||
|
||||
async with SessionLocal() as s:
|
||||
assert await s.get(Trace, trace_id) is None # aged out
|
||||
assert await s.get(Span, span_id) is None # its span too
|
||||
assert await s.get(Run, old_run_id) is None
|
||||
assert await s.get(Run, recent_run_id) is not None # within horizon -> kept
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Import / export of tools, workflows, components, and agents (PortabilityService).
|
||||
|
||||
Covers the guarantees the feature promises: a faithful round-trip (every authored field
|
||||
survives), fresh ids on import, auto-rename that never overwrites, intra-bundle id remap
|
||||
(a workflow's subworkflow ref follows the new ids), tool auth-provider resolution against
|
||||
the target project, runtime-junk stripping, and bundle-type validation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.main import create_app
|
||||
from forge.models import AuthProvider, Component
|
||||
from forge.services.agents import AgentService
|
||||
from forge.services.components import ComponentService
|
||||
from forge.services.portability import PortabilityService
|
||||
from forge.services.projects import ProjectService
|
||||
from forge.services.tools import ToolService
|
||||
from forge.services.versions import VersionService
|
||||
from forge.services.workflows import WorkflowService
|
||||
|
||||
AUTHOR = types.SimpleNamespace(id="u_importer", email="importer@forge.local")
|
||||
|
||||
|
||||
async def _project(session, tenant_id, slug):
|
||||
return await ProjectService.create(session, tenant_id, name=slug.title(), slug=slug)
|
||||
|
||||
|
||||
async def test_tool_export_import_roundtrip_strips_runtime_and_clears_missing_auth():
|
||||
tenant = "t_tool_rt"
|
||||
async with SessionLocal() as session:
|
||||
src = await _project(session, tenant, "src")
|
||||
dst = await _project(session, tenant, "dst")
|
||||
ap = AuthProvider(tenant_id=tenant, project_id=src.id, name="bearer", kind="bearer", config={})
|
||||
session.add(ap)
|
||||
await session.commit()
|
||||
cfg = {
|
||||
"description": "call the widget API",
|
||||
"request": {"method": "GET", "url_template": "https://api.example.com/widgets"},
|
||||
"response": {"projection_jmespath": "data"},
|
||||
"_last_test": {"status": 200, "raw": "junk"}, # runtime state; must not export
|
||||
}
|
||||
tool = await ToolService.create(
|
||||
session, tenant, src.id, name="widget_api", kind="rest_api", config=cfg, auth_provider_id=ap.id
|
||||
)
|
||||
|
||||
bundle = await PortabilityService.export(session, tenant, src.id, "tool", [tool.id])
|
||||
assert bundle["type"] == "tool"
|
||||
assert bundle["source"]["project_name"] == "Src"
|
||||
assert len(bundle["items"]) == 1
|
||||
item = bundle["items"][0]
|
||||
assert item["name"] == "widget_api"
|
||||
assert "_last_test" not in item["config"] # runtime junk stripped
|
||||
assert item["config"]["request"]["url_template"] == "https://api.example.com/widgets"
|
||||
assert item["auth_provider_id"] == ap.id
|
||||
|
||||
# Import into a DIFFERENT project that has no such auth provider.
|
||||
report = await PortabilityService.import_bundle(session, tenant, dst.id, bundle, author=AUTHOR)
|
||||
assert report["imported"] == 1
|
||||
assert report["items"][0]["renamed"] is False
|
||||
assert any("auth provider" in w for w in report["warnings"])
|
||||
|
||||
# Filter out the auto-provisioned platform built-ins (never part of a bundle).
|
||||
imported = [t for t in await ToolService.list(session, tenant, dst.id) if t.kind != "builtin"]
|
||||
assert len(imported) == 1
|
||||
it = imported[0]
|
||||
assert it.id != tool.id # fresh id
|
||||
assert it.name == "widget_api"
|
||||
assert it.config["request"]["url_template"] == "https://api.example.com/widgets"
|
||||
assert "_last_test" not in it.config
|
||||
assert it.auth_provider_id is None # cleared: provider not in target project
|
||||
|
||||
# A version-history snapshot was recorded (imported == created + saved).
|
||||
versions = await VersionService.list(session, tenant, "tool", it.id)
|
||||
assert len(versions) >= 1
|
||||
|
||||
|
||||
async def test_tool_auth_provider_kept_when_present_in_target():
|
||||
tenant = "t_tool_ap"
|
||||
async with SessionLocal() as session:
|
||||
src = await _project(session, tenant, "src")
|
||||
dst = await _project(session, tenant, "dst")
|
||||
# Same provider id must exist in the target for it to survive; simulate a same-project
|
||||
# re-import by importing back into src (its provider exists).
|
||||
ap = AuthProvider(tenant_id=tenant, project_id=src.id, name="bearer", kind="bearer", config={})
|
||||
session.add(ap)
|
||||
await session.commit()
|
||||
tool = await ToolService.create(
|
||||
session, tenant, src.id, name="api", kind="rest_api",
|
||||
config={"description": "x", "request": {"method": "GET", "url_template": "https://x.test"}},
|
||||
auth_provider_id=ap.id,
|
||||
)
|
||||
bundle = await PortabilityService.export(session, tenant, src.id, "tool", [tool.id])
|
||||
report = await PortabilityService.import_bundle(session, tenant, src.id, bundle, author=AUTHOR)
|
||||
assert report["imported"] == 1
|
||||
# Re-imported into src: renamed (name clash) but auth kept.
|
||||
new_id = report["items"][0]["id"]
|
||||
assert report["items"][0]["renamed"] is True
|
||||
tools = {t.id: t for t in await ToolService.list(session, tenant, src.id)}
|
||||
assert tools[new_id].auth_provider_id == ap.id
|
||||
assert not any("auth provider" in w for w in report["warnings"])
|
||||
assert dst # (unused target kept for symmetry)
|
||||
|
||||
|
||||
async def test_auto_rename_never_overwrites_on_repeated_import():
|
||||
tenant = "t_rename"
|
||||
async with SessionLocal() as session:
|
||||
src = await _project(session, tenant, "src")
|
||||
tool = await ToolService.create(
|
||||
session, tenant, src.id, name="lookup", kind="rest_api",
|
||||
config={"description": "look things up", "request": {"method": "GET", "url_template": "https://x.test"}},
|
||||
)
|
||||
bundle = await PortabilityService.export(session, tenant, src.id, "tool", [tool.id])
|
||||
r1 = await PortabilityService.import_bundle(session, tenant, src.id, bundle, author=AUTHOR)
|
||||
r2 = await PortabilityService.import_bundle(session, tenant, src.id, bundle, author=AUTHOR)
|
||||
# Filter out the auto-provisioned platform built-ins (never part of a bundle).
|
||||
names = sorted(t.name for t in await ToolService.list(session, tenant, src.id) if t.kind != "builtin")
|
||||
assert names == ["lookup", "lookup_imported", "lookup_imported_2"]
|
||||
assert r1["items"][0]["name"] == "lookup_imported"
|
||||
assert r2["items"][0]["name"] == "lookup_imported_2"
|
||||
|
||||
|
||||
async def test_component_unique_name_autorenames_without_conflict():
|
||||
tenant = "t_comp"
|
||||
async with SessionLocal() as session:
|
||||
src = await _project(session, tenant, "src")
|
||||
comp = await ComponentService.create(
|
||||
session, tenant, src.id, name="product_card", title="Card",
|
||||
html="<div>{{title}}</div>", css=".x{}", props_schema={"type": "object"},
|
||||
sample_props={"title": "Hi"}, actions=[{"id": "buy", "label": "Buy"}],
|
||||
)
|
||||
comp.enabled = False
|
||||
await session.commit()
|
||||
|
||||
bundle = await PortabilityService.export(session, tenant, src.id, "component", [comp.id])
|
||||
item = bundle["items"][0]
|
||||
assert item["html"] == "<div>{{title}}</div>"
|
||||
assert item["actions"] == [{"id": "buy", "label": "Buy"}]
|
||||
assert item["enabled"] is False
|
||||
|
||||
# Import back into the same project twice: the unique-name constraint must never trip.
|
||||
await PortabilityService.import_bundle(session, tenant, src.id, bundle, author=AUTHOR)
|
||||
await PortabilityService.import_bundle(session, tenant, src.id, bundle, author=AUTHOR)
|
||||
comps = sorted(c.name for c in await ComponentService.list(session, tenant, src.id))
|
||||
assert comps == ["product_card", "product_card_imported", "product_card_imported_2"]
|
||||
# Round-tripped fields survive (incl. the disabled state).
|
||||
imported = [c for c in await ComponentService.list(session, tenant, src.id) if c.name == "product_card_imported"][0]
|
||||
assert imported.enabled is False
|
||||
assert imported.actions == [{"id": "buy", "label": "Buy"}]
|
||||
|
||||
|
||||
async def test_agent_config_preserved_and_attribution_is_importer():
|
||||
tenant = "t_agent"
|
||||
async with SessionLocal() as session:
|
||||
src = await _project(session, tenant, "src")
|
||||
dst = await _project(session, tenant, "dst")
|
||||
config = {
|
||||
"flavor": "agent",
|
||||
"model": "openai:gpt-4o-mini",
|
||||
"system_prompt": "Be helpful.",
|
||||
"tools": ["some_tool_id"],
|
||||
"components": ["some_component_id"],
|
||||
"middleware": [{"type": "summarization", "enabled": True}],
|
||||
}
|
||||
agent = await AgentService.create(
|
||||
session, tenant, src.id, name="support", config=config,
|
||||
created_by="orig_user", created_by_email="orig@forge.local",
|
||||
)
|
||||
bundle = await PortabilityService.export(session, tenant, src.id, "agent", [agent.id])
|
||||
assert bundle["items"][0]["created_by_email"] == "orig@forge.local"
|
||||
|
||||
await PortabilityService.import_bundle(session, tenant, dst.id, bundle, author=AUTHOR)
|
||||
imported = (await AgentService.list(session, tenant, dst.id))[0]
|
||||
assert imported.config == config # full config verbatim (nothing dropped)
|
||||
assert imported.created_by == "u_importer"
|
||||
assert imported.created_by_email == "importer@forge.local"
|
||||
|
||||
|
||||
async def test_workflow_subworkflow_reference_is_remapped_to_new_ids():
|
||||
tenant = "t_wf"
|
||||
async with SessionLocal() as session:
|
||||
src = await _project(session, tenant, "src")
|
||||
dst = await _project(session, tenant, "dst")
|
||||
child = await WorkflowService.create(
|
||||
session, tenant, src.id, name="child",
|
||||
executable={"id": "child", "version": 1, "nodes": [], "edges": []},
|
||||
canvas={"nodes": [], "edges": []},
|
||||
)
|
||||
parent = await WorkflowService.create(
|
||||
session, tenant, src.id, name="parent",
|
||||
executable={
|
||||
"id": "parent", "version": 1,
|
||||
"nodes": [{"id": "sub_1", "type": "subworkflow", "config": {"workflow_id": child.id}}],
|
||||
"edges": [],
|
||||
},
|
||||
canvas={"nodes": [], "edges": []},
|
||||
)
|
||||
|
||||
bundle = await PortabilityService.export(session, tenant, src.id, "workflow", [child.id, parent.id])
|
||||
report = await PortabilityService.import_bundle(session, tenant, dst.id, bundle, author=AUTHOR)
|
||||
assert report["imported"] == 2
|
||||
|
||||
wfs = {w.name: w for w in await WorkflowService.list(session, tenant, dst.id)}
|
||||
new_child, new_parent = wfs["child"], wfs["parent"]
|
||||
assert new_child.id != child.id and new_parent.id != parent.id
|
||||
ref = new_parent.executable["nodes"][0]["config"]["workflow_id"]
|
||||
assert ref == new_child.id # remapped to the freshly-created child, not the old id
|
||||
# Imported workflows land as drafts (publish is a separate, governance-checked action).
|
||||
assert new_parent.status == "draft"
|
||||
|
||||
|
||||
async def test_import_rejects_wrong_bundle_type():
|
||||
tenant = "t_type"
|
||||
async with SessionLocal() as session:
|
||||
dst = await _project(session, tenant, "dst")
|
||||
with pytest.raises(ValueError):
|
||||
await PortabilityService.import_bundle(
|
||||
session, tenant, dst.id, {"type": "nonsense", "items": []}, author=AUTHOR
|
||||
)
|
||||
|
||||
|
||||
def _client() -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app()), base_url="http://test")
|
||||
|
||||
|
||||
async def _owner(c: httpx.AsyncClient) -> dict:
|
||||
reg = (await c.post("/v1/auth/register", json={"email": f"u{uuid.uuid4().hex[:8]}@x.com", "password": "supersecret1"})).json()
|
||||
return {"Authorization": f"Bearer {reg['access_token']}"}
|
||||
|
||||
|
||||
async def test_http_component_export_import_roundtrip_across_projects():
|
||||
"""Full HTTP path: create → export → import into another project, over the real routes."""
|
||||
async with _client() as c:
|
||||
h = await _owner(c)
|
||||
src = (await c.post("/v1/projects", json={"name": "Source"}, headers=h)).json()
|
||||
dst = (await c.post("/v1/projects", json={"name": "Dest"}, headers=h)).json()
|
||||
comp = (await c.post(
|
||||
f"/v1/projects/{src['id']}/components",
|
||||
json={"name": "price_card", "title": "Price", "html": "<b>{{p}}</b>", "css": ".b{}",
|
||||
"props_schema": {"type": "object"}, "sample_props": {"p": 9}, "actions": []},
|
||||
headers=h,
|
||||
)).json()
|
||||
|
||||
exp = await c.post(f"/v1/projects/{src['id']}/components/export", json={"ids": [comp["id"]]}, headers=h)
|
||||
assert exp.status_code == 200, exp.text
|
||||
bundle = exp.json()
|
||||
assert bundle["type"] == "component" and len(bundle["items"]) == 1
|
||||
|
||||
imp = await c.post(f"/v1/projects/{dst['id']}/components/import", json=bundle, headers=h)
|
||||
assert imp.status_code == 200, imp.text
|
||||
report = imp.json()
|
||||
assert report["imported"] == 1 and report["type"] == "component"
|
||||
|
||||
listed = (await c.get(f"/v1/projects/{dst['id']}/components", headers=h)).json()
|
||||
assert [x["name"] for x in listed] == ["price_card"]
|
||||
assert listed[0]["html"] == "<b>{{p}}</b>"
|
||||
|
||||
|
||||
async def test_http_import_wrong_type_returns_422():
|
||||
async with _client() as c:
|
||||
h = await _owner(c)
|
||||
pid = (await c.post("/v1/projects", json={"name": "P"}, headers=h)).json()["id"]
|
||||
# An agent bundle posted to the tools import endpoint must be rejected clearly.
|
||||
r = await c.post(f"/v1/projects/{pid}/tools/import", json={"type": "agent", "items": []}, headers=h)
|
||||
assert r.status_code == 422
|
||||
assert "agent" in r.json()["detail"]
|
||||
|
||||
|
||||
async def test_export_ignores_ids_from_other_projects():
|
||||
tenant = "t_scope"
|
||||
async with SessionLocal() as session:
|
||||
src = await _project(session, tenant, "src")
|
||||
other = await _project(session, tenant, "other")
|
||||
mine = await ToolService.create(session, tenant, src.id, name="mine", kind="rest_api", config={"request": {"method": "GET", "url_template": "https://x.test"}})
|
||||
theirs = await ToolService.create(session, tenant, other.id, name="theirs", kind="rest_api", config={"request": {"method": "GET", "url_template": "https://y.test"}})
|
||||
bundle = await PortabilityService.export(session, tenant, src.id, "tool", [mine.id, theirs.id])
|
||||
names = [i["name"] for i in bundle["items"]]
|
||||
assert names == ["mine"] # cross-project id silently dropped
|
||||
assert Component # import kept tidy
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Admin pricing overrides overlay the built-in defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.tracing.pricing import load_overrides, merged_prices, price, set_override
|
||||
|
||||
|
||||
def test_default_pricing_applies():
|
||||
# gpt-4o-mini default is (0.15, 0.6) per 1M
|
||||
cost = price("gpt-4o-mini", 1_000_000, 0)
|
||||
assert abs(cost - 0.15) < 1e-9
|
||||
|
||||
|
||||
def test_override_takes_precedence_and_matches_bare_name():
|
||||
set_override("gpt-4o-mini", 1.0, 2.0)
|
||||
try:
|
||||
assert abs(price("gpt-4o-mini", 1_000_000, 0) - 1.0) < 1e-9
|
||||
assert abs(price("openai:gpt-4o-mini", 0, 1_000_000) - 2.0) < 1e-9 # provider-prefixed resolves bare
|
||||
assert merged_prices()["gpt-4o-mini"] == (1.0, 2.0)
|
||||
finally:
|
||||
load_overrides({}) # reset so other tests see defaults
|
||||
|
||||
|
||||
def test_unknown_model_prices_zero():
|
||||
assert price("totally-unknown-model", 1000, 1000) == 0.0
|
||||
@@ -0,0 +1,162 @@
|
||||
"""The single project-level run endpoint (POST /v1/projects/{id}/run).
|
||||
|
||||
One generic surface over the existing run machinery: it runs the project's *configured*
|
||||
workflow (config.api_workflow_id), takes `stream` as the only per-request knob, and routes a
|
||||
`resume` body to the HITL machinery. These tests drive it in-process over ASGI with a fake
|
||||
model, so no real LLM is called.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from forge.main import create_app
|
||||
|
||||
# A trivial one-agent workflow whose fake model always answers with this exact text - lets us
|
||||
# assert the endpoint actually ran the configured workflow end to end.
|
||||
_ANSWER = "Hello from Forge."
|
||||
_WF = {
|
||||
"id": "wf_run_ep", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "agent",
|
||||
"nodes": [
|
||||
{"id": "agent", "type": "agent", "config": {"flavor": "agent", "model": f"fake:{_ANSWER}", "tools": []}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "agent", "target": "end"}],
|
||||
}
|
||||
|
||||
|
||||
def _client() -> httpx.AsyncClient:
|
||||
app = create_app()
|
||||
# No app lifespan runs in-process, so hand the run service a checkpointer directly
|
||||
# (aget_state needs one); prod gets this from the lifespan.
|
||||
app.state.checkpointer = InMemorySaver()
|
||||
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test")
|
||||
|
||||
|
||||
def _email() -> str:
|
||||
return f"u{uuid.uuid4().hex[:10]}@example.com"
|
||||
|
||||
|
||||
async def _project_with_configured_workflow(c: httpx.AsyncClient) -> tuple[dict, str]:
|
||||
"""Register an owner, create a project + workflow, and pin the workflow as the project's
|
||||
API workflow. Returns (auth header, project_id)."""
|
||||
reg = (await c.post("/v1/auth/register", json={"email": _email(), "password": "supersecret1"})).json()
|
||||
h = {"Authorization": f"Bearer {reg['access_token']}"}
|
||||
pid = (await c.post("/v1/projects", json={"name": "Run API Project"}, headers=h)).json()["id"]
|
||||
wid = (await c.post(f"/v1/projects/{pid}/workflows", json={"name": "Chat", "executable": _WF}, headers=h)).json()["id"]
|
||||
# The saved project setting that designates which workflow the /run endpoint executes.
|
||||
r = await c.patch(f"/v1/projects/{pid}", json={"config": {"api_workflow_id": wid}}, headers=h)
|
||||
assert r.status_code == 200, r.text
|
||||
return h, pid
|
||||
|
||||
|
||||
async def test_run_non_stream_returns_answer_and_thread():
|
||||
async with _client() as c:
|
||||
h, pid = await _project_with_configured_workflow(c)
|
||||
r = await c.post(
|
||||
f"/v1/projects/{pid}/run",
|
||||
json={"input": {"messages": [{"role": "user", "content": "hi"}]}, "stream": False},
|
||||
headers=h,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert _ANSWER in (body.get("answer") or ""), body
|
||||
assert body.get("thread_id"), body
|
||||
assert body.get("status") == "done", body
|
||||
|
||||
|
||||
async def test_run_stream_emits_ready_and_done_frames():
|
||||
async with _client() as c:
|
||||
h, pid = await _project_with_configured_workflow(c)
|
||||
r = await c.post(
|
||||
f"/v1/projects/{pid}/run",
|
||||
json={"input": {"messages": [{"role": "user", "content": "hi"}]}, "stream": True},
|
||||
headers=h,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
text = r.text
|
||||
# A leading `ready` frame hands the caller the canonical thread_id, and the run
|
||||
# finishes with a `done` frame carrying the answer.
|
||||
assert "event: ready" in text, text
|
||||
assert '"thread_id"' in text
|
||||
assert "event: done" in text, text
|
||||
assert _ANSWER in text
|
||||
|
||||
|
||||
async def test_thread_id_from_run_continues_conversation():
|
||||
async with _client() as c:
|
||||
h, pid = await _project_with_configured_workflow(c)
|
||||
first = (await c.post(
|
||||
f"/v1/projects/{pid}/run",
|
||||
json={"input": {"messages": [{"role": "user", "content": "hi"}]}, "stream": False},
|
||||
headers=h,
|
||||
)).json()
|
||||
tid = first["thread_id"]
|
||||
# Reusing the thread_id must be accepted (the checkpointer holds the history).
|
||||
r = await c.post(
|
||||
f"/v1/projects/{pid}/run",
|
||||
json={"thread_id": tid, "input": {"messages": [{"role": "user", "content": "again"}]}, "stream": False},
|
||||
headers=h,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json().get("thread_id") == tid
|
||||
|
||||
|
||||
async def test_stream_exposes_one_consistent_thread_id_that_continues():
|
||||
"""Regression (shared chat memory): the streaming path once handed the caller TWO different
|
||||
thread handles - the DB Thread.id in the `ready` frame and the composite LangGraph id
|
||||
(`{tenant}:{uuid}`) in the `run` frame. A caller that stored the `run` frame's id echoed a
|
||||
handle that never matched Thread.id, so every turn spun up a fresh thread and the agent
|
||||
"forgot" the conversation. Every thread_id in the stream must now be identical and reusable."""
|
||||
import re
|
||||
|
||||
async with _client() as c:
|
||||
h, pid = await _project_with_configured_workflow(c)
|
||||
r = await c.post(
|
||||
f"/v1/projects/{pid}/run",
|
||||
json={"input": {"messages": [{"role": "user", "content": "hi"}]}, "stream": True},
|
||||
headers=h,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
tids = re.findall(r'"thread_id":\s*"([^"]+)"', r.text)
|
||||
assert tids, r.text
|
||||
assert len(set(tids)) == 1, f"stream exposed conflicting thread handles: {set(tids)}"
|
||||
# The handle from the stream must reattach to the same thread (checkpointer holds history).
|
||||
again = (await c.post(
|
||||
f"/v1/projects/{pid}/run",
|
||||
json={"thread_id": tids[0], "input": {"messages": [{"role": "user", "content": "again"}]}, "stream": False},
|
||||
headers=h,
|
||||
)).json()
|
||||
assert again.get("thread_id") == tids[0]
|
||||
|
||||
|
||||
async def test_project_without_a_workflow_is_404():
|
||||
async with _client() as c:
|
||||
reg = (await c.post("/v1/auth/register", json={"email": _email(), "password": "supersecret1"})).json()
|
||||
h = {"Authorization": f"Bearer {reg['access_token']}"}
|
||||
pid = (await c.post("/v1/projects", json={"name": "Empty"}, headers=h)).json()["id"]
|
||||
r = await c.post(f"/v1/projects/{pid}/run", json={"input": {}, "stream": False}, headers=h)
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
async def test_resume_requires_thread_id():
|
||||
async with _client() as c:
|
||||
h, pid = await _project_with_configured_workflow(c)
|
||||
r = await c.post(f"/v1/projects/{pid}/run", json={"resume": {"value": "approve"}}, headers=h)
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
async def test_resume_with_no_interrupted_run_is_409():
|
||||
async with _client() as c:
|
||||
h, pid = await _project_with_configured_workflow(c)
|
||||
r = await c.post(
|
||||
f"/v1/projects/{pid}/run",
|
||||
json={"thread_id": "does-not-exist", "resume": {"value": "approve"}},
|
||||
headers=h,
|
||||
)
|
||||
assert r.status_code == 409, r.text
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Project lifecycle tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import (
|
||||
Agent,
|
||||
AuthProvider,
|
||||
Component,
|
||||
HandoffRequest,
|
||||
KbSource,
|
||||
McpClient,
|
||||
QaPair,
|
||||
Run,
|
||||
Secret,
|
||||
Span,
|
||||
Thread,
|
||||
Tool,
|
||||
Trace,
|
||||
Workflow,
|
||||
)
|
||||
from forge.services.projects import ProjectService
|
||||
|
||||
|
||||
async def _count(session, model, **where) -> int:
|
||||
stmt = select(func.count()).select_from(model)
|
||||
for key, value in where.items():
|
||||
stmt = stmt.where(getattr(model, key) == value)
|
||||
return int((await session.execute(stmt)).scalar_one())
|
||||
|
||||
|
||||
async def test_project_counts_are_scoped_to_project():
|
||||
tenant_id = "tenant_counts"
|
||||
async with SessionLocal() as session:
|
||||
proj = await ProjectService.create(session, tenant_id, name="Counts", slug="counts")
|
||||
other = await ProjectService.create(session, tenant_id, name="Other", slug="other")
|
||||
session.add_all([
|
||||
Workflow(tenant_id=tenant_id, project_id=proj.id, name="wf1"),
|
||||
Workflow(tenant_id=tenant_id, project_id=proj.id, name="wf2"),
|
||||
Agent(tenant_id=tenant_id, project_id=proj.id, name="a", config={}),
|
||||
Tool(tenant_id=tenant_id, project_id=proj.id, name="t1", kind="builtin", config={}),
|
||||
Tool(tenant_id=tenant_id, project_id=proj.id, name="t2", kind="builtin", config={}),
|
||||
Tool(tenant_id=tenant_id, project_id=proj.id, name="t3", kind="builtin", config={}),
|
||||
Component(tenant_id=tenant_id, project_id=proj.id, name="card"),
|
||||
KbSource(tenant_id=tenant_id, project_id=proj.id, kind="text", name="src"),
|
||||
AuthProvider(tenant_id=tenant_id, project_id=proj.id, name="auth", kind="bearer", config={}),
|
||||
HandoffRequest(tenant_id=tenant_id, project_id=proj.id, run_id="run-open", status="open"),
|
||||
HandoffRequest(tenant_id=tenant_id, project_id=proj.id, run_id="run-done", status="answered"),
|
||||
# Belongs to a different project in the same tenant - must NOT be counted for `proj`.
|
||||
Tool(tenant_id=tenant_id, project_id=other.id, name="other_tool", kind="builtin", config={}),
|
||||
Workflow(tenant_id=tenant_id, project_id=other.id, name="other_wf"),
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
# ProjectService.create auto-provisions the platform built-ins, so each project starts with
|
||||
# len(BUILTIN_DEFAULTS) tools before the 3 added here. Scoping still holds: `other`'s tool
|
||||
# (and its own provisioned built-ins) are not counted for `proj`.
|
||||
from forge.tools.builtin import BUILTIN_DEFAULTS
|
||||
counts = await ProjectService.counts(session, tenant_id, proj.id)
|
||||
assert counts == {
|
||||
"workflows": 2, "agents": 1, "tools": 3 + len(BUILTIN_DEFAULTS),
|
||||
"components": 1, "knowledge": 1, "auth": 1, "handoffs": 1,
|
||||
}
|
||||
# A fresh project is all zeros except the auto-provisioned platform built-ins (never None).
|
||||
empty = await ProjectService.create(session, tenant_id, name="Empty", slug="empty")
|
||||
assert await ProjectService.counts(session, tenant_id, empty.id) == {
|
||||
"workflows": 0, "agents": 0, "tools": len(BUILTIN_DEFAULTS), "components": 0, "knowledge": 0, "auth": 0,
|
||||
"handoffs": 0,
|
||||
}
|
||||
|
||||
|
||||
async def test_delete_project_removes_project_scoped_data_and_trace_spans():
|
||||
tenant_id = "tenant_delete_project"
|
||||
deleted_threads: list[str] = []
|
||||
|
||||
class FakeCheckpointer:
|
||||
async def adelete_thread(self, thread_id: str) -> None:
|
||||
deleted_threads.append(thread_id)
|
||||
|
||||
async with SessionLocal() as session:
|
||||
project = await ProjectService.create(session, tenant_id, name="Delete Me", slug="delete-me")
|
||||
workflow = Workflow(tenant_id=tenant_id, project_id=project.id, name="wf")
|
||||
thread = Thread(tenant_id=tenant_id, project_id=project.id, workflow_id="wf1", lg_thread_id="lg1")
|
||||
run = Run(tenant_id=tenant_id, project_id=project.id, workflow_id="wf1", thread_id="thread1")
|
||||
trace = Trace(tenant_id=tenant_id, project_id=project.id, workflow_id="wf1", run_id="run1", name="trace")
|
||||
session.add_all([workflow, thread, run, trace])
|
||||
await session.flush()
|
||||
session.add_all([
|
||||
Span(tenant_id=tenant_id, trace_id=trace.id, name="span", kind="node"),
|
||||
Agent(tenant_id=tenant_id, project_id=project.id, name="agent", config={}),
|
||||
Tool(tenant_id=tenant_id, project_id=project.id, name="tool", kind="builtin", config={}),
|
||||
AuthProvider(tenant_id=tenant_id, project_id=project.id, name="auth", kind="bearer", config={}),
|
||||
Secret(tenant_id=tenant_id, project_id=project.id, name="secret", kind="api_key", encrypted_value=b"x"),
|
||||
KbSource(tenant_id=tenant_id, project_id=project.id, kind="text", name="source"),
|
||||
QaPair(tenant_id=tenant_id, project_id=project.id, question="q", answer="a"),
|
||||
McpClient(tenant_id=tenant_id, project_id=project.id, name="mcp"),
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
await ProjectService.delete(session, project, checkpointer=FakeCheckpointer())
|
||||
|
||||
assert await ProjectService.get(session, tenant_id, project.id) is None
|
||||
for model in (Workflow, Thread, Run, Trace, Agent, Tool, AuthProvider, Secret, KbSource, QaPair, McpClient):
|
||||
assert await _count(session, model, project_id=project.id) == 0
|
||||
assert await _count(session, Span, trace_id=trace.id) == 0
|
||||
assert deleted_threads == ["lg1"]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Q&A semantic match now runs through the vector store (forge_qa_<dim>), not a
|
||||
Python O(n) cosine over every row."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import QaPair
|
||||
from forge.services.knowledge import KnowledgeService
|
||||
|
||||
T, P = "t_qa_vs", "p_qa_vs"
|
||||
|
||||
|
||||
async def _seed():
|
||||
async with SessionLocal() as s:
|
||||
await KnowledgeService.create_qa(s, T, P, question="How do I reset my password?", answer="Use the reset link on the login page.", kind="faq")
|
||||
await KnowledgeService.create_qa(s, T, P, question="What are your support hours?", answer="9am-5pm ET, Mon-Fri.", kind="hours")
|
||||
|
||||
|
||||
async def test_top_qa_ranks_semantically():
|
||||
await _seed()
|
||||
async with SessionLocal() as s:
|
||||
hits = await KnowledgeService.top_qa(s, T, P, "I forgot my password and need to reset it", top_k=1, threshold=0.0)
|
||||
assert hits and "reset link" in hits[0]["answer"]
|
||||
|
||||
|
||||
async def test_lookup_kind_filter():
|
||||
await _seed()
|
||||
async with SessionLocal() as s:
|
||||
# restricting to the 'hours' kind must not return the password FAQ
|
||||
hit = await KnowledgeService.lookup(s, T, P, "reset password", threshold=0.0, kinds=["hours"])
|
||||
assert hit is not None and "9am-5pm" in hit["answer"]
|
||||
|
||||
|
||||
async def test_lazy_reindex_backfills_existing_rows():
|
||||
# Insert a QaPair directly (bypassing create_qa's upsert) to simulate pre-existing data,
|
||||
# then top_qa must still find it via the lazy reindex.
|
||||
async with SessionLocal() as s:
|
||||
emb = await (await KnowledgeService.embedder_for_project(s, "t_bf", "p_bf")).aembed_query("billing question")
|
||||
s.add(QaPair(tenant_id="t_bf", project_id="p_bf", question="How do I update my billing card?",
|
||||
answer="Settings → Billing → Update card.", kind="faq", q_embedding=emb))
|
||||
await s.commit()
|
||||
async with SessionLocal() as s:
|
||||
hits = await KnowledgeService.top_qa(s, "t_bf", "p_bf", "change my billing card", top_k=1, threshold=0.0)
|
||||
assert hits and "Billing" in hits[0]["answer"]
|
||||
|
||||
|
||||
async def test_update_qa_replaces_question_and_retrieval_metadata():
|
||||
tenant_id, project_id = "t_qa_edit", "p_qa_edit"
|
||||
async with SessionLocal() as s:
|
||||
qa = await KnowledgeService.create_qa(
|
||||
s, tenant_id, project_id, question="Where is the old handbook?",
|
||||
answer="On the old portal.", kind="legacy", tags=["old"],
|
||||
)
|
||||
updated = await KnowledgeService.update_qa(
|
||||
s, qa, question="Where is the employee handbook?",
|
||||
answer="Open People, then Documents.", kind="hr", tags=["people"],
|
||||
)
|
||||
hits = await KnowledgeService.top_qa(
|
||||
s, tenant_id, project_id, "employee handbook", top_k=1, threshold=0.0,
|
||||
)
|
||||
|
||||
assert updated.question == "Where is the employee handbook?"
|
||||
assert updated.kind == "hr" and updated.tags == ["people"]
|
||||
assert hits and hits[0]["answer"] == "Open People, then Documents."
|
||||
assert hits[0]["kind"] == "hr"
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Per-tenant daily quota, centralized mutation auditing, and the scoping helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.db.scoping import tenant_scoped
|
||||
from forge.main import create_app
|
||||
from forge.models import Run, Tenant
|
||||
from forge.services.quota import QuotaExceeded, check_run_quota, usage_today
|
||||
|
||||
|
||||
def _email() -> str:
|
||||
return f"u{uuid.uuid4().hex[:10]}@example.com"
|
||||
|
||||
|
||||
# --- 1.7 quota ---
|
||||
|
||||
|
||||
async def test_quota_blocks_when_daily_run_cap_reached():
|
||||
async with SessionLocal() as s:
|
||||
t = Tenant(name="Q", settings={"max_runs_per_day": 1})
|
||||
s.add(t)
|
||||
await s.flush()
|
||||
s.add(Run(tenant_id=t.id, project_id="p", workflow_id="w", thread_id="th", status="done"))
|
||||
await s.commit()
|
||||
tid = t.id
|
||||
async with SessionLocal() as s:
|
||||
with pytest.raises(QuotaExceeded):
|
||||
await check_run_quota(s, tid)
|
||||
usage = await usage_today(s, tid)
|
||||
assert usage["runs"] == 1 and usage["limits"]["max_runs_per_day"] == 1
|
||||
|
||||
|
||||
async def test_no_quota_when_unset():
|
||||
async with SessionLocal() as s:
|
||||
t = Tenant(name="NoQ", settings={})
|
||||
s.add(t)
|
||||
await s.commit()
|
||||
await check_run_quota(s, t.id) # must not raise
|
||||
|
||||
|
||||
# --- 1.10 scoping helper ---
|
||||
|
||||
|
||||
def test_tenant_scoped_adds_filters():
|
||||
from sqlalchemy import select
|
||||
|
||||
from forge.models import Workflow
|
||||
sql = str(tenant_scoped(select(Workflow), Workflow, "t1", project_id="p1"))
|
||||
assert "tenant_id" in sql and "project_id" in sql
|
||||
|
||||
|
||||
# --- 1.8 audit middleware ---
|
||||
|
||||
|
||||
async def test_mutations_are_audited():
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
reg = (await c.post("/v1/auth/register", json={"email": _email(), "password": "supersecret1"})).json()
|
||||
h = {"Authorization": f"Bearer {reg['access_token']}"}
|
||||
|
||||
r = await c.post("/v1/projects", json={"name": "Audited Project"}, headers=h)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
audit = (await c.get("/v1/audit", headers=h)).json()
|
||||
actions = [a["action"] for a in audit]
|
||||
assert any(a == "POST /v1/projects" for a in actions), actions
|
||||
# auth endpoints are NOT double-audited by the middleware
|
||||
assert "POST /v1/auth/register" not in actions
|
||||
|
||||
|
||||
async def test_audit_action_uses_route_template_and_keeps_concrete_path():
|
||||
"""A mutating request to a UUID path is audited as the route TEMPLATE, not the concrete
|
||||
path. The template fits the action column (String(80)) and stays aggregatable; the concrete
|
||||
path (with real ids) is preserved in meta for forensics. Regression for the varchar(80)
|
||||
overflow that silently dropped every long-path audit row on Postgres."""
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
reg = (await c.post("/v1/auth/register", json={"email": _email(), "password": "supersecret1"})).json()
|
||||
h = {"Authorization": f"Bearer {reg['access_token']}"}
|
||||
|
||||
pid = (await c.post("/v1/projects", json={"name": "P"}, headers=h)).json()["id"]
|
||||
r = await c.patch(f"/v1/projects/{pid}", json={"name": "Renamed"}, headers=h)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
audit = (await c.get("/v1/audit", headers=h)).json()
|
||||
patch_rows = [a for a in audit if a["action"].startswith("PATCH ")]
|
||||
assert patch_rows, [a["action"] for a in audit]
|
||||
row = patch_rows[0]
|
||||
# templated (no UUID) -> within String(80) and aggregatable across projects
|
||||
assert row["action"] == "PATCH /v1/projects/{project_id}"
|
||||
assert len(row["action"]) <= 80
|
||||
# concrete path with the real id is retained in meta
|
||||
assert row["meta"]["path"] == f"/v1/projects/{pid}"
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Audit fixes for the RAG relevance floor + ingestion/retrieval gaps.
|
||||
|
||||
Covers, per the feature audit:
|
||||
- effective cosine grounding floor (calibrated for the default BGE embedder) end to end,
|
||||
- min_score thresholding the TRUE cosine in hybrid mode (not the fused rank),
|
||||
- source-provenance citations persisted on chunks,
|
||||
- crawl honoring robots.txt + max_depth,
|
||||
- CSV/JSON per-record parsing + binary-upload rejection + HTML stripping,
|
||||
- chunk_size clamped to the embedder's input limit,
|
||||
- long-term-memory recall similarity floor.
|
||||
|
||||
Model-backed tests use the local fastembed embedder and skip cleanly when it can't load
|
||||
(offline), mirroring the other knowledge tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from forge.knowledge.crawl import MAX_DEPTH_CAP, MAX_PAGES_CAP
|
||||
from forge.knowledge.embeddings import DEFAULT_MIN_SCORE, DEFAULT_RERANK_MIN_SCORE, _max_input_chars
|
||||
from forge.knowledge.store import Hit, citation_for
|
||||
from forge.nodes.rag import _passes_floor
|
||||
from forge.routers.knowledge import _csv_to_text, _decode_upload, _json_to_text
|
||||
|
||||
|
||||
def _require_embedder():
|
||||
"""Return the local BGE embedder or skip (offline / model not cached)."""
|
||||
pytest.importorskip("fastembed")
|
||||
from forge.knowledge.embeddings import resolve_embedder
|
||||
|
||||
try:
|
||||
e = resolve_embedder(None)
|
||||
except Exception: # noqa: BLE001
|
||||
pytest.skip("fastembed model could not be loaded (offline)")
|
||||
if getattr(e, "name", "") != "BAAI/bge-small-en-v1.5":
|
||||
pytest.skip("fastembed model unavailable")
|
||||
return e
|
||||
|
||||
|
||||
# --- calibrated floor defaults (pure) ---
|
||||
|
||||
def test_default_floors_are_calibrated():
|
||||
# BGE unrelated pairs measure ~0.4-0.52, related ~0.75+, so the floor must sit between.
|
||||
assert 0.55 <= DEFAULT_MIN_SCORE <= 0.7
|
||||
assert 0.0 < DEFAULT_RERANK_MIN_SCORE < DEFAULT_MIN_SCORE
|
||||
|
||||
|
||||
# --- min_score on the right scale (pure) ---
|
||||
|
||||
def test_passes_floor_uses_cosine_not_fused_rank():
|
||||
# vector-only: Hit.score IS the cosine.
|
||||
assert _passes_floor(Hit("a", "t", 0.70, {}), 0.6, hybrid=False) is True
|
||||
assert _passes_floor(Hit("a", "t", 0.50, {}), 0.6, hybrid=False) is False
|
||||
# hybrid: Hit.score is the fused rank (top≈1.0); the floor must use vector_score.
|
||||
assert _passes_floor(Hit("a", "t", 1.0, {}, vector_score=0.50), 0.6, hybrid=True) is False
|
||||
assert _passes_floor(Hit("a", "t", 1.0, {}, vector_score=0.70), 0.6, hybrid=True) is True
|
||||
# a BM25-only hit has no cosine -> kept (a strong exact-term match isn't floored out).
|
||||
assert _passes_floor(Hit("a", "t", 0.9, {}, vector_score=None), 0.6, hybrid=True) is True
|
||||
|
||||
|
||||
# --- citations from chunk provenance (pure) ---
|
||||
|
||||
def test_citation_for_prefers_page_then_source():
|
||||
assert citation_for({"page_url": "https://x.test/pricing", "page_title": "pricing"}) == "pricing — https://x.test/pricing"
|
||||
assert citation_for({"page_url": "https://x.test/p"}) == "https://x.test/p"
|
||||
assert citation_for({"source_name": "help", "source_uri": "https://x.test/help"}) == "help — https://x.test/help"
|
||||
assert citation_for({"source_name": "manual"}) == "manual"
|
||||
assert citation_for({}) == ""
|
||||
assert citation_for(None) == ""
|
||||
|
||||
|
||||
# --- BM25 cache split: index build + score parity (pure) ---
|
||||
|
||||
def test_bm25_build_and_score_match_one_shot():
|
||||
pytest.importorskip("rank_bm25")
|
||||
from forge.knowledge.hybrid import bm25_rank, bm25_scores, build_bm25
|
||||
|
||||
docs = [
|
||||
("d1", "general refund and shipping policy details"),
|
||||
("d2", "error code XJ9000 means a payment gateway timeout"),
|
||||
("d3", "how to contact our support team"),
|
||||
]
|
||||
idx = build_bm25(docs)
|
||||
assert bm25_scores(idx, "XJ9000 gateway timeout")[0] == "d2"
|
||||
# The cached-index path must match the one-shot bm25_rank exactly.
|
||||
assert bm25_scores(idx, "XJ9000 gateway timeout") == bm25_rank("XJ9000 gateway timeout", docs)
|
||||
assert bm25_scores(idx, "zzz qqq") == []
|
||||
assert bm25_scores(None, "anything") == []
|
||||
assert build_bm25([]) is None
|
||||
|
||||
|
||||
# --- chunk_size vs embedder token limit (pure) ---
|
||||
|
||||
def test_max_input_chars_by_model():
|
||||
assert _max_input_chars("BAAI/bge-small-en-v1.5") == 512 * 4
|
||||
assert _max_input_chars("text-embedding-3-small") == 8191 * 4
|
||||
assert _max_input_chars("some-unknown-model") == 512 * 4 # conservative default
|
||||
|
||||
|
||||
# --- CSV/JSON per-record parsing + upload guards (pure) ---
|
||||
|
||||
def test_csv_parsed_into_header_qualified_records():
|
||||
out = _csv_to_text("name,plan,seats\nAcme,enterprise,50\nBeta,free,3")
|
||||
assert "name: Acme" in out and "plan: enterprise" in out and "seats: 50" in out
|
||||
assert "name: Beta" in out
|
||||
assert "\n\n" in out # records separated so the chunker can split on record boundaries
|
||||
|
||||
|
||||
def test_csv_without_data_rows_falls_back():
|
||||
assert _csv_to_text("only one line") == "only one line"
|
||||
|
||||
|
||||
def test_json_list_of_objects_becomes_records():
|
||||
out = _json_to_text('[{"q": "hi", "a": "there"}, {"q": "bye", "a": "now"}]')
|
||||
assert "q: hi | a: there" in out
|
||||
assert "q: bye | a: now" in out
|
||||
assert "\n\n" in out
|
||||
|
||||
|
||||
def test_json_single_list_value_is_expanded():
|
||||
out = _json_to_text('{"items": [{"k": 1}, {"k": 2}]}')
|
||||
assert "k: 1" in out and "k: 2" in out
|
||||
|
||||
|
||||
def test_json_invalid_falls_back_to_raw():
|
||||
assert _json_to_text("not json {{{") == "not json {{{"
|
||||
|
||||
|
||||
def test_decode_upload_rejects_binary_extension():
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
_decode_upload("report.docx", b"PK\x03\x04 not really text")
|
||||
assert ei.value.status_code == 422
|
||||
|
||||
|
||||
def test_decode_upload_rejects_null_byte_binary():
|
||||
with pytest.raises(HTTPException):
|
||||
_decode_upload("mystery.dat", b"text\x00\x00\x01\x02 binary")
|
||||
|
||||
|
||||
def test_decode_upload_strips_html():
|
||||
out = _decode_upload("page.html", b"<html><body><h1>Hi</h1><p>There</p></body></html>")
|
||||
assert "Hi" in out and "There" in out
|
||||
assert "<h1>" not in out and "<body>" not in out
|
||||
|
||||
|
||||
def test_decode_upload_plain_text_and_csv_dispatch():
|
||||
assert _decode_upload("notes.txt", b"just some notes") == "just some notes"
|
||||
out = _decode_upload("data.csv", b"a,b\n1,2")
|
||||
assert "a: 1" in out and "b: 2" in out
|
||||
|
||||
|
||||
# --- crawl: robots.txt + depth (monkeypatched network, offline) ---
|
||||
|
||||
def test_crawl_caps_are_bounded():
|
||||
assert MAX_PAGES_CAP <= 500 and MAX_DEPTH_CAP <= 10
|
||||
|
||||
|
||||
async def test_crawl_honors_robots_and_max_depth(monkeypatch):
|
||||
import forge.util.ssrf as ssrf
|
||||
from forge.knowledge import crawl as crawl_mod
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, text: str, status: int = 200) -> None:
|
||||
self.text = text
|
||||
self.status_code = status
|
||||
|
||||
site = {
|
||||
"https://acme.test/robots.txt": _Resp("User-agent: *\nDisallow: /private\n"),
|
||||
"https://acme.test/": _Resp('<a href="/a">A</a> <a href="/private">P</a> <a href="/b">B</a>'),
|
||||
"https://acme.test/a": _Resp('<a href="/c">C</a> alpha body'),
|
||||
"https://acme.test/b": _Resp("bee body"),
|
||||
"https://acme.test/c": _Resp("cee body"),
|
||||
"https://acme.test/private": _Resp("secret body"),
|
||||
}
|
||||
|
||||
async def fake_get(client, url, **kw):
|
||||
if url in site:
|
||||
return site[url]
|
||||
raise RuntimeError("404")
|
||||
|
||||
monkeypatch.setattr(ssrf, "guarded_get", fake_get)
|
||||
pages = await crawl_mod.crawl_site("https://acme.test/", max_pages=50, max_depth=1, delay=0.0)
|
||||
|
||||
crawled = set(pages) # exact-URL membership (not substring) so this stays a set lookup
|
||||
assert "https://acme.test/" in crawled
|
||||
assert "https://acme.test/a" in crawled and "https://acme.test/b" in crawled
|
||||
assert "https://acme.test/private" not in crawled # robots.txt Disallow honored
|
||||
assert "https://acme.test/c" not in crawled # one hop beyond max_depth=1
|
||||
|
||||
|
||||
# --- model-backed end-to-end ---
|
||||
|
||||
async def test_offtopic_query_is_floored_and_on_topic_cites(tmp_path):
|
||||
_require_embedder()
|
||||
from langchain_core.messages import SystemMessage
|
||||
|
||||
from forge.config import settings
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.nodes.rag import retrieval_factory
|
||||
from forge.services.knowledge import KnowledgeService
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma_floor")
|
||||
t, p = "t_floor", "p_floor"
|
||||
async with SessionLocal() as s:
|
||||
src = await KnowledgeService.create_source(
|
||||
s, t, p, kind="text", name="refunds",
|
||||
text="Refunds are issued to the original payment method within 5-7 business days.",
|
||||
)
|
||||
await KnowledgeService.ingest(s, src)
|
||||
|
||||
node = retrieval_factory({"announce_empty": True, "top_k": 3}, CompileContext(tenant_id=t, project_id=p))
|
||||
off = await node({"messages": [{"role": "user", "content": "what is the capital of France?"}]})
|
||||
assert isinstance(off["messages"][-1], SystemMessage)
|
||||
assert "no relevant" in off["messages"][-1].content.lower() # off-topic floored -> empty note
|
||||
|
||||
on = await node({"messages": [{"role": "user", "content": "how long do refunds take?"}]})
|
||||
body = on["messages"][-1].content
|
||||
assert "KNOWLEDGE BASE context" in body
|
||||
assert "Refunds" in body
|
||||
assert "refunds" in body.lower().split("] ")[0] # citation label carries the source name
|
||||
|
||||
|
||||
async def test_hybrid_hit_carries_true_cosine_vector_score(tmp_path):
|
||||
_require_embedder()
|
||||
from forge.config import settings
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.services.knowledge import KnowledgeService
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma_hy_vs")
|
||||
t, p = "t_hyvs", "p_hyvs"
|
||||
async with SessionLocal() as s:
|
||||
for i, txt in enumerate([
|
||||
"Refunds go to the original card within five business days.",
|
||||
"Error code XJ9000 indicates a payment gateway timeout; retry after 30 seconds.",
|
||||
"Cancel an order from the Orders page before it ships.",
|
||||
]):
|
||||
src = await KnowledgeService.create_source(s, t, p, kind="text", name=f"d{i}", text=txt)
|
||||
await KnowledgeService.ingest(s, src)
|
||||
hits = await KnowledgeService.search(s, t, p, "XJ9000 timeout", top_k=3, hybrid=True)
|
||||
assert hits
|
||||
assert hits[0].score == 1.0 # fused rank still normalized to 1.0 at the top
|
||||
# vector_score is the underlying cosine (0..1), a different scale from the fused score.
|
||||
assert any(h.vector_score is not None for h in hits)
|
||||
assert all(h.vector_score is None or 0.0 <= h.vector_score <= 1.0 for h in hits)
|
||||
|
||||
|
||||
async def test_ingest_persists_source_citation_metadata(tmp_path):
|
||||
_require_embedder()
|
||||
from forge.config import settings
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.services.knowledge import KnowledgeService
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma_cite")
|
||||
t, p = "t_cite", "p_cite"
|
||||
async with SessionLocal() as s:
|
||||
src = await KnowledgeService.create_source(
|
||||
s, t, p, kind="text", name="Refund Policy",
|
||||
text="Refunds are issued to the original payment method within five business days.",
|
||||
)
|
||||
await KnowledgeService.ingest(s, src)
|
||||
hits = await KnowledgeService.search(s, t, p, "refund timing", top_k=2)
|
||||
assert hits
|
||||
assert hits[0].metadata.get("source_name") == "Refund Policy"
|
||||
assert hits[0].metadata.get("embedding_model") == "BAAI/bge-small-en-v1.5"
|
||||
assert citation_for(hits[0].metadata) == "Refund Policy"
|
||||
|
||||
|
||||
async def test_ingest_clamps_chunk_size_to_embedder_limit(tmp_path):
|
||||
_require_embedder()
|
||||
from forge.config import settings
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import Project
|
||||
from forge.services.knowledge import KnowledgeService
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma_clamp")
|
||||
async with SessionLocal() as s:
|
||||
proj = Project(tenant_id="t_cl", name="Cl", slug="clamp", config={"rag_defaults": {"chunk_size": 100000}})
|
||||
s.add(proj)
|
||||
await s.commit()
|
||||
await s.refresh(proj)
|
||||
src = await KnowledgeService.create_source(s, "t_cl", proj.id, kind="text", name="big", text="word " * 800)
|
||||
src = await KnowledgeService.ingest(s, src)
|
||||
assert src.status == "ready"
|
||||
assert src.chunk_size <= _max_input_chars("BAAI/bge-small-en-v1.5") # clamped down
|
||||
assert (src.meta or {}).get("chunk_size_requested") == 100000 # original recorded
|
||||
|
||||
|
||||
async def test_memory_recall_similarity_floor(tmp_path):
|
||||
_require_embedder()
|
||||
from forge.config import settings
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.services.memory import MemoryService
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma_memfloor")
|
||||
t, p = "t_memf", "p_memf"
|
||||
async with SessionLocal() as s:
|
||||
await MemoryService.remember(s, t, p, "Our refund window is 30 days.")
|
||||
|
||||
original = settings.memory_recall_min_score
|
||||
try:
|
||||
settings.memory_recall_min_score = 0.6
|
||||
async with SessionLocal() as s:
|
||||
off = await MemoryService.recall(s, t, p, "the capital of France", top_k=5)
|
||||
assert off == [] # unrelated memory filtered by the floor
|
||||
settings.memory_recall_min_score = 0.0
|
||||
async with SessionLocal() as s:
|
||||
on = await MemoryService.recall(s, t, p, "the capital of France", top_k=5)
|
||||
assert on # floor off (default) -> nearest returned regardless of distance
|
||||
finally:
|
||||
settings.memory_recall_min_score = original
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Production RAG upgrades: cross-encoder rerank (graceful), native semantic chunking,
|
||||
and parent-child retrieval. Q&A logic is deliberately NOT exercised here - it is unchanged."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.knowledge.rerank import _sigmoid, rerank_hits
|
||||
from forge.knowledge.splitter import _percentile, chunk_text
|
||||
from forge.knowledge.store import Hit
|
||||
from forge.models import Project
|
||||
from forge.services.knowledge import KnowledgeService
|
||||
|
||||
# --- reranker: pure + graceful degradation (no model download needed) ---
|
||||
|
||||
|
||||
def test_sigmoid_bounds():
|
||||
assert _sigmoid(0.0) == 0.5
|
||||
assert _sigmoid(20) > 0.99 and _sigmoid(-20) < 0.01
|
||||
|
||||
|
||||
def test_rerank_missing_model_is_identity_truncated_to_top_k():
|
||||
hits = [Hit(id=str(i), text=f"doc {i}", score=0.9 - i * 0.1, metadata={}) for i in range(5)]
|
||||
out = rerank_hits("anything", hits, top_k=3, model="does/not-exist")
|
||||
assert [h.id for h in out] == ["0", "1", "2"] # unchanged order, capped
|
||||
|
||||
|
||||
def test_rerank_empty_hits_and_empty_query():
|
||||
assert rerank_hits("q", [], top_k=3) == []
|
||||
hits = [Hit(id="a", text="x", score=0.5, metadata={})]
|
||||
assert rerank_hits(" ", hits, top_k=3) == hits # blank query short-circuits
|
||||
|
||||
|
||||
def test_rerank_reorders_by_relevance():
|
||||
"""Real cross-encoder (small, cached after first run) must lift the relevant doc."""
|
||||
hits = [
|
||||
Hit(id="weather", text="The weather in Paris is mild in spring.", score=0.9, metadata={}),
|
||||
Hit(id="fruit", text="Bananas are a good source of potassium.", score=0.8, metadata={}),
|
||||
Hit(id="reset", text="To reset your password, use the Forgot Password link on the login screen.", score=0.1, metadata={}),
|
||||
]
|
||||
out = rerank_hits("how do I reset my password?", hits, top_k=2)
|
||||
assert out[0].id == "reset"
|
||||
assert 0.0 <= out[0].score <= 1.0
|
||||
|
||||
|
||||
# --- semantic chunking: pure, deterministic via a fake embedder ---
|
||||
|
||||
|
||||
def test_percentile_interpolates():
|
||||
assert _percentile([0.0, 1.0], 50) == 0.5
|
||||
assert _percentile([1.0], 95) == 1.0
|
||||
assert _percentile([], 95) == 0.0
|
||||
|
||||
|
||||
def test_semantic_splits_on_topic_shift():
|
||||
# A fake embedder: refund sentences -> [1,0]; rocket sentences -> [0,1]. The single
|
||||
# boundary between the two topics is the sharpest similarity drop -> exactly one cut.
|
||||
refunds = "Refunds are issued to your original card. We process them within five days. Contact support for status."
|
||||
rockets = "Rockets burn liquid oxygen. The first stage separates after ascent. Reentry heats the shield."
|
||||
text = refunds + " " + rockets
|
||||
|
||||
def fake_embed(sentences):
|
||||
return [[1.0, 0.0] if "efund" in s or "process" in s or "support" in s else [0.0, 1.0] for s in sentences]
|
||||
|
||||
chunks = chunk_text(text, strategy="semantic", chunk_size=1000, overlap=0, embed_fn=fake_embed)
|
||||
assert len(chunks) == 2
|
||||
assert "Refunds" in chunks[0] and "Rockets" in chunks[1]
|
||||
|
||||
|
||||
def test_semantic_without_embed_fn_falls_back_to_recursive():
|
||||
text = ("Sentence one here. Sentence two here. Sentence three here. " * 20).strip()
|
||||
got = chunk_text(text, strategy="semantic", chunk_size=200, overlap=40)
|
||||
expected = chunk_text(text, strategy="recursive", chunk_size=200, overlap=40)
|
||||
assert got == expected
|
||||
|
||||
|
||||
def test_semantic_too_few_sentences_falls_back():
|
||||
def boom(_s):
|
||||
raise AssertionError("embed_fn must not be called for <3 sentences")
|
||||
|
||||
assert chunk_text("Only one sentence here.", strategy="semantic", embed_fn=boom) == ["Only one sentence here."]
|
||||
|
||||
|
||||
# --- semantic + parent-child: ingest wiring (uses the real local embedder) ---
|
||||
|
||||
|
||||
async def _make_project(slug: str, rag_defaults: dict) -> str:
|
||||
async with SessionLocal() as s:
|
||||
proj = Project(tenant_id="t_rag", name="Rag", slug=slug, config={"rag_defaults": rag_defaults})
|
||||
s.add(proj)
|
||||
await s.commit()
|
||||
await s.refresh(proj)
|
||||
return proj.id
|
||||
|
||||
|
||||
async def test_semantic_ingest_does_not_crash(tmp_path):
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma_sem")
|
||||
pid = await _make_project("rag-semantic", {"chunking_strategy": "semantic"})
|
||||
text = ("Refunds go to the original card within five business days. Shipping takes two days. "
|
||||
"Error XJ9000 is a gateway timeout; retry after 30 seconds. Our office is in Berlin. ") * 3
|
||||
async with SessionLocal() as s:
|
||||
src = await KnowledgeService.create_source(s, "t_rag", pid, kind="text", name="d", text=text)
|
||||
src = await KnowledgeService.ingest(s, src)
|
||||
assert src.status == "ready"
|
||||
assert src.chunks >= 1
|
||||
assert src.chunking_strategy == "semantic"
|
||||
|
||||
|
||||
async def test_parent_child_ingest_and_retrieval(tmp_path):
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma_pc")
|
||||
pid = await _make_project("rag-parentchild", {"retrieval_mode": "parent_child", "chunk_size": 400, "child_chunk_size": 120})
|
||||
parent_a = ("Refund policy. Refunds are issued to the original payment method within five to "
|
||||
"seven business days once the returned item is received and inspected at our warehouse.")
|
||||
parent_b = ("Shipping policy. Standard orders ship within two business days and arrive in about "
|
||||
"a week; expedited shipping is available at checkout for an additional fee.")
|
||||
async with SessionLocal() as s:
|
||||
src = await KnowledgeService.create_source(s, "t_rag", pid, kind="text", name="policies",
|
||||
text=parent_a + "\n\n" + parent_b)
|
||||
src = await KnowledgeService.ingest(s, src)
|
||||
assert src.status == "ready"
|
||||
assert (src.meta or {}).get("retrieval_mode") == "parent_child"
|
||||
assert (src.meta or {}).get("parents", 0) >= 1
|
||||
assert src.chunks >= (src.meta or {}).get("parents") # at least one child per parent
|
||||
|
||||
hits = await KnowledgeService.search(s, "t_rag", pid, "how long does a refund take?", top_k=2)
|
||||
assert hits
|
||||
top = hits[0]
|
||||
# Retrieval returns the PARENT window (the full paragraph), not just the matched child slice.
|
||||
assert "five to seven business days" in top.text
|
||||
assert top.metadata.get("parent_id")
|
||||
# De-dup by parent: no two returned hits share a parent_id.
|
||||
parent_ids = [h.metadata.get("parent_id") for h in hits]
|
||||
assert len(parent_ids) == len(set(parent_ids))
|
||||
|
||||
|
||||
async def test_chunk_map_projects_and_marks_retrieved(tmp_path):
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma_map")
|
||||
pid = await _make_project("rag-map", {"chunk_size": 200})
|
||||
async with SessionLocal() as s:
|
||||
for i, t in enumerate([
|
||||
"Refunds go to the original payment method within five business days.",
|
||||
"Error code XJ9000 indicates a payment gateway timeout; retry after 30 seconds.",
|
||||
"Standard orders ship within two business days from our warehouse.",
|
||||
]):
|
||||
src = await KnowledgeService.create_source(s, "t_rag", pid, kind="text", name=f"d{i}", text=t)
|
||||
await KnowledgeService.ingest(s, src)
|
||||
res = await KnowledgeService.chunk_map(s, "t_rag", pid, query="how long for a refund?", top_k=2)
|
||||
assert res["total"] >= 3
|
||||
assert len(res["points"]) >= 3
|
||||
# every point has 2-D coords + a source
|
||||
for p in res["points"]:
|
||||
assert isinstance(p["x"], float) and isinstance(p["y"], float)
|
||||
assert p["source_id"]
|
||||
assert res["query_point"] and len(res["query_point"]) == 2
|
||||
assert res["sources"] # legend populated
|
||||
# the query overlay tagged at least one chunk as retrieved (rank 1..top_k)
|
||||
ranks = [p.get("retrieved") for p in res["points"] if p.get("retrieved")]
|
||||
assert ranks and min(ranks) == 1
|
||||
|
||||
|
||||
async def test_chunk_map_empty_project_is_safe(tmp_path):
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma_map_empty")
|
||||
pid = await _make_project("rag-map-empty", {})
|
||||
async with SessionLocal() as s:
|
||||
res = await KnowledgeService.chunk_map(s, "t_rag", pid, query="anything")
|
||||
assert res == {"points": [], "sources": [], "query_point": None, "query": "anything", "total": 0, "truncated": False}
|
||||
|
||||
|
||||
async def test_flat_mode_unchanged(tmp_path):
|
||||
"""Default (no retrieval_mode) still returns plain chunks with no parent metadata."""
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma_flat")
|
||||
pid = await _make_project("rag-flat", {})
|
||||
async with SessionLocal() as s:
|
||||
src = await KnowledgeService.create_source(s, "t_rag", pid, kind="text", name="d",
|
||||
text="Refunds are issued within five business days.")
|
||||
await KnowledgeService.ingest(s, src)
|
||||
hits = await KnowledgeService.search(s, "t_rag", pid, "refund time", top_k=3)
|
||||
assert hits
|
||||
assert not hits[0].metadata.get("parent_id") # flat: no parent-child metadata
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Rate limiter + idempotency cache unit tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.util.ratelimit import IdempotencyCache, RateLimiter
|
||||
|
||||
|
||||
def test_token_bucket_blocks_after_burst():
|
||||
rl = RateLimiter()
|
||||
# rate=5/min, burst=5 → first 5 allowed, 6th blocked
|
||||
allowed = [rl.allow("k", rate=5, per=60, burst=5) for _ in range(6)]
|
||||
assert allowed == [True, True, True, True, True, False]
|
||||
|
||||
|
||||
def test_zero_rate_is_unlimited():
|
||||
rl = RateLimiter()
|
||||
assert all(rl.allow("k", rate=0) for _ in range(100))
|
||||
|
||||
|
||||
def test_keys_are_independent():
|
||||
rl = RateLimiter()
|
||||
assert rl.allow("a", rate=1, burst=1) is True
|
||||
assert rl.allow("a", rate=1, burst=1) is False
|
||||
assert rl.allow("b", rate=1, burst=1) is True # different key unaffected
|
||||
|
||||
|
||||
def test_idempotency_returns_stored_value():
|
||||
cache = IdempotencyCache(ttl_seconds=60)
|
||||
assert cache.get("x") is None
|
||||
cache.put("x", {"run_id": "r1"})
|
||||
assert cache.get("x") == {"run_id": "r1"}
|
||||
|
||||
|
||||
def test_idempotency_expires():
|
||||
cache = IdempotencyCache(ttl_seconds=-1) # already expired
|
||||
cache.put("x", 1)
|
||||
assert cache.get("x") is None
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Per-source re-chunking overrides (strategy / size / overlap) and the local
|
||||
open-source fastembed embedder resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.knowledge.embeddings import resolve_embedder
|
||||
from forge.services.knowledge import KnowledgeService
|
||||
|
||||
# --- re-chunk overrides ---
|
||||
|
||||
_BIG = " ".join(f"Refund policy note {i}: widgets ship within five business days." for i in range(120))
|
||||
|
||||
|
||||
async def test_ingest_records_chunk_settings(tmp_path):
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma_rec")
|
||||
async with SessionLocal() as s:
|
||||
src = await KnowledgeService.create_source(s, "t_rec", "p_rec", kind="text", name="big", text=_BIG)
|
||||
src = await KnowledgeService.ingest(s, src)
|
||||
# Defaults (no project rag_defaults) are recorded in meta so the UI can display them.
|
||||
assert src.chunking_strategy == "recursive"
|
||||
assert src.chunk_size == 1000
|
||||
assert src.chunk_overlap == 200
|
||||
assert src.chunks >= 1
|
||||
|
||||
|
||||
async def test_rechunk_smaller_size_yields_more_chunks(tmp_path):
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma_rc")
|
||||
async with SessionLocal() as s:
|
||||
src = await KnowledgeService.create_source(s, "t_rc", "p_rc", kind="text", name="big", text=_BIG)
|
||||
src = await KnowledgeService.ingest(s, src)
|
||||
base = src.chunks
|
||||
# Shrinking the chunk size must split the same text into more pieces...
|
||||
src = await KnowledgeService.rechunk(s, src, chunk_size=200, chunk_overlap=20)
|
||||
assert src.chunk_size == 200
|
||||
assert src.chunk_overlap == 20
|
||||
assert src.chunks > base
|
||||
|
||||
|
||||
async def test_rechunk_honors_zero_overlap(tmp_path):
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma_ov0")
|
||||
async with SessionLocal() as s:
|
||||
src = await KnowledgeService.create_source(s, "t_ov0", "p_ov0", kind="text", name="big", text=_BIG)
|
||||
src = await KnowledgeService.ingest(s, src)
|
||||
# An explicit 0 overlap must stick (not silently reset to the 200 default).
|
||||
src = await KnowledgeService.rechunk(s, src, chunk_size=300, chunk_overlap=0)
|
||||
assert src.chunk_overlap == 0
|
||||
assert src.chunk_size == 300
|
||||
|
||||
|
||||
async def test_rechunk_strategy_only_preserves_size(tmp_path):
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma_rcs")
|
||||
async with SessionLocal() as s:
|
||||
src = await KnowledgeService.create_source(s, "t_rcs", "p_rcs", kind="text", name="big", text=_BIG)
|
||||
src = await KnowledgeService.ingest(s, src)
|
||||
# ...while a strategy-only re-chunk leaves size/overlap untouched (None = keep).
|
||||
src = await KnowledgeService.rechunk(s, src, chunking_strategy="sentence")
|
||||
assert src.chunking_strategy == "sentence"
|
||||
assert src.chunk_size == 1000
|
||||
assert src.chunk_overlap == 200
|
||||
|
||||
|
||||
# --- fastembed resolution (local, open-source) ---
|
||||
|
||||
|
||||
def test_fastembed_resolves_local_model():
|
||||
pytest.importorskip("fastembed")
|
||||
try:
|
||||
e = resolve_embedder("fastembed:BAAI/bge-small-en-v1.5")
|
||||
except RuntimeError:
|
||||
# No toy fallback anymore - resolve_embedder raises if the model can't load
|
||||
# (e.g. offline with no cached model). Skip rather than fail the suite.
|
||||
pytest.skip("fastembed model could not be loaded (offline)")
|
||||
assert e.name == "BAAI/bge-small-en-v1.5"
|
||||
assert e.dim == 384
|
||||
assert len(e.embed_query("hello world")) == 384
|
||||
|
||||
|
||||
def test_default_embedder_is_local_fastembed():
|
||||
"""With no model ref, resolve_embedder returns the local open-source default
|
||||
(no FakeEmbedder). Guards the 'remove fake embedder' decision."""
|
||||
pytest.importorskip("fastembed")
|
||||
try:
|
||||
e = resolve_embedder(None)
|
||||
except RuntimeError:
|
||||
pytest.skip("fastembed model could not be loaded (offline)")
|
||||
assert e.name == "BAAI/bge-small-en-v1.5" and e.dim == 384
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Re-chunk / re-embed must REPLACE a source's vectors, never accumulate stale ones.
|
||||
|
||||
Guards the "chunk count keeps increasing every time I re-embed" class of bug: reingest deletes
|
||||
the source's old vectors (in the current dim collection AND every known dim collection) before
|
||||
re-ingesting, so the store row count always equals the source's reported chunk count.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.knowledge.store import _where
|
||||
from forge.models import Project
|
||||
from forge.services.knowledge import KnowledgeService
|
||||
|
||||
_TEXT = ("Refunds go to the original card within five business days. Shipping takes two days. "
|
||||
"Error XJ9000 is a gateway timeout; retry after 30 seconds. Our office is in Berlin. ") * 5
|
||||
|
||||
|
||||
async def _make_project(slug: str, rag_defaults: dict) -> str:
|
||||
async with SessionLocal() as s:
|
||||
proj = Project(tenant_id="t_rc", name="Rc", slug=slug, config={"rag_defaults": rag_defaults})
|
||||
s.add(proj)
|
||||
await s.commit()
|
||||
await s.refresh(proj)
|
||||
return proj.id
|
||||
|
||||
|
||||
def _rows(embedder, pid, sid) -> int:
|
||||
return KnowledgeService._store(embedder).count_where(_where("t_rc", pid, [sid]))
|
||||
|
||||
|
||||
async def test_rechunk_replaces_not_accumulates(tmp_path):
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma_rc")
|
||||
pid = await _make_project("rc", {"chunk_size": 200, "chunk_overlap": 0})
|
||||
async with SessionLocal() as s:
|
||||
src = await KnowledgeService.create_source(s, "t_rc", pid, kind="text", name="d", text=_TEXT)
|
||||
src = await KnowledgeService.ingest(s, src)
|
||||
embedder = await KnowledgeService.embedder_for_project(s, "t_rc", pid)
|
||||
assert _rows(embedder, pid, src.id) == src.chunks
|
||||
|
||||
# Bigger chunks -> fewer rows; smaller -> more. Row count must track src.chunks each time,
|
||||
# proving the previous run's vectors were deleted, not left behind.
|
||||
src = await KnowledgeService.rechunk(s, src, chunk_size=4000, chunk_overlap=0)
|
||||
assert _rows(embedder, pid, src.id) == src.chunks
|
||||
src = await KnowledgeService.rechunk(s, src, chunk_size=150, chunk_overlap=0)
|
||||
assert _rows(embedder, pid, src.id) == src.chunks
|
||||
|
||||
|
||||
async def test_reembed_same_settings_stays_flat(tmp_path):
|
||||
"""The exact 'Apply & re-embed' path (run_ingest_bg with reingest=True) repeated with
|
||||
UNCHANGED settings must not grow the row count."""
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma_bg")
|
||||
pid = await _make_project("bg", {"chunk_size": 180, "chunk_overlap": 0})
|
||||
async with SessionLocal() as s:
|
||||
src = await KnowledgeService.create_source(s, "t_rc", pid, kind="text", name="d", text=_TEXT)
|
||||
sid = src.id
|
||||
embedder = await KnowledgeService.embedder_for_project(s, "t_rc", pid)
|
||||
|
||||
counts = []
|
||||
for _ in range(3):
|
||||
await KnowledgeService.run_ingest_bg("t_rc", sid, reingest=True)
|
||||
counts.append(_rows(embedder, pid, sid))
|
||||
assert counts[0] == counts[1] == counts[2], f"re-embed accumulated stale rows: {counts}"
|
||||
|
||||
|
||||
async def test_dedupe_removes_exact_duplicate_chunks(tmp_path):
|
||||
"""The same document ingested twice -> identical chunks; dedupe keeps one copy of each and
|
||||
fixes the affected sources' counts."""
|
||||
from forge.config import settings
|
||||
|
||||
settings.chroma_path = str(tmp_path / "chroma_dedupe")
|
||||
pid = await _make_project("dedupe", {"chunk_size": 200, "chunk_overlap": 0})
|
||||
async with SessionLocal() as s:
|
||||
a = await KnowledgeService.create_source(s, "t_rc", pid, kind="text", name="dup.md", text=_TEXT)
|
||||
a = await KnowledgeService.ingest(s, a)
|
||||
b = await KnowledgeService.create_source(s, "t_rc", pid, kind="text", name="dup-copy.md", text=_TEXT)
|
||||
b = await KnowledgeService.ingest(s, b)
|
||||
embedder = await KnowledgeService.embedder_for_project(s, "t_rc", pid)
|
||||
|
||||
before = _rows(embedder, pid, a.id) + _rows(embedder, pid, b.id)
|
||||
res = await KnowledgeService.dedupe_chunks(s, "t_rc", pid)
|
||||
after = _rows(embedder, pid, a.id) + _rows(embedder, pid, b.id)
|
||||
|
||||
# Two identical docs -> every chunk had exactly one duplicate; half are removed.
|
||||
assert res["removed"] == before // 2
|
||||
assert res["remaining"] == after
|
||||
# A second run is a no-op (idempotent).
|
||||
async with SessionLocal() as s:
|
||||
res2 = await KnowledgeService.dedupe_chunks(s, "t_rc", pid)
|
||||
assert res2["removed"] == 0
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Service-tool redirect handling.
|
||||
|
||||
Two behaviours, both new:
|
||||
1. A 3xx that is NOT followed (the default) is no longer an empty response - the
|
||||
target `location` is captured and surfaced to the model.
|
||||
2. With `request.follow_redirects` on, redirects are chased SSRF-safely via
|
||||
`guarded_request`: the final URL + hop chain are captured, and a hop pointing at
|
||||
a blocked (private/metadata) address is refused.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from forge.tools import rest as rest_mod
|
||||
from forge.tools.rest import build_rest_tool, execute_rest
|
||||
from forge.util.ssrf import EgressBlocked, EgressPolicy
|
||||
|
||||
|
||||
def _cfg(**extra) -> dict:
|
||||
return {
|
||||
"name": f"t_{uuid.uuid4().hex[:8]}",
|
||||
"kind": "rest_api",
|
||||
"request": {"method": "GET", "url_template": "https://api.acme.dev/go", "fields": []},
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def _redirecting_client(location: str, *, status: int = 302) -> httpx.AsyncClient:
|
||||
"""Mock client: /go -> 3xx(location); the target -> 200 JSON."""
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
if req.url.path == "/go":
|
||||
return httpx.Response(status, headers={"location": location})
|
||||
return httpx.Response(200, json={"arrived": True, "path": req.url.path})
|
||||
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
# --- 1. capture without following (default) --------------------------------------
|
||||
|
||||
async def test_unfollowed_3xx_captures_location():
|
||||
client = _redirecting_client("https://api.acme.dev/v2/final")
|
||||
res = await execute_rest(_cfg(), {}, tenant_id="t", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
|
||||
assert res["status"] == 302
|
||||
assert res["redirect"] is not None
|
||||
assert res["redirect"]["followed"] is False
|
||||
assert res["redirect"]["location"] == "https://api.acme.dev/v2/final"
|
||||
|
||||
|
||||
async def test_unfollowed_redirect_is_wrapped_for_the_agent():
|
||||
"""The StructuredTool the agent calls must surface the redirect, not an empty body."""
|
||||
client = _redirecting_client("https://api.acme.dev/v2/final")
|
||||
rest_mod_select = rest_mod.select_client
|
||||
rest_mod.select_client = lambda *a, **k: client # type: ignore[assignment]
|
||||
try:
|
||||
ctx = types.SimpleNamespace(tenant_id="t", project_id="p", auth_resolver=None, egress_policy=None)
|
||||
tool = build_rest_tool(_cfg(), ctx)
|
||||
out = await tool.ainvoke({})
|
||||
finally:
|
||||
rest_mod.select_client = rest_mod_select # type: ignore[assignment]
|
||||
await client.aclose()
|
||||
|
||||
assert isinstance(out, dict) and "redirect" in out
|
||||
assert out["redirect"]["location"] == "https://api.acme.dev/v2/final"
|
||||
|
||||
|
||||
async def test_tool_test_preview_counts_the_redirect_not_an_empty_body():
|
||||
"""The /test preview (ToolService.test) must reflect what the agent actually gets:
|
||||
the {body, redirect} envelope, not the empty 3xx body. Otherwise 'PROJECTED -> MODEL'
|
||||
reads "" / 0 tok and looks like nothing reaches the model."""
|
||||
from forge.services.tools import ToolService
|
||||
|
||||
client = _redirecting_client("/shop/addproduct/PROD-001")
|
||||
rest_mod_select = rest_mod.select_client
|
||||
rest_mod.select_client = lambda *a, **k: client # type: ignore[assignment]
|
||||
try:
|
||||
r = await ToolService.test("t", "p", _cfg(), {})
|
||||
finally:
|
||||
rest_mod.select_client = rest_mod_select # type: ignore[assignment]
|
||||
await client.aclose()
|
||||
|
||||
assert r["ok"] and r["redirect"]["location"] == "/shop/addproduct/PROD-001"
|
||||
# raw + projected now carry the observation shape, and the location survives into it.
|
||||
assert isinstance(r["projected"], dict)
|
||||
assert r["projected"]["redirect"]["location"] == "/shop/addproduct/PROD-001"
|
||||
assert isinstance(r["raw"], dict) and "redirect" in r["raw"]
|
||||
# ...so the meter shows a real cost instead of the old 0 tok.
|
||||
assert r["projected_tokens"] > 0 and r["raw_tokens"] > 0
|
||||
|
||||
|
||||
async def test_tool_test_preview_leaves_non_redirect_body_bare():
|
||||
"""No redirect -> the preview stays the bare body (unchanged), not an envelope."""
|
||||
from forge.services.tools import ToolService
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, json={"v": 1})))
|
||||
rest_mod_select = rest_mod.select_client
|
||||
rest_mod.select_client = lambda *a, **k: client # type: ignore[assignment]
|
||||
try:
|
||||
r = await ToolService.test("t", "p", _cfg(), {})
|
||||
finally:
|
||||
rest_mod.select_client = rest_mod_select # type: ignore[assignment]
|
||||
await client.aclose()
|
||||
|
||||
assert r["ok"] and r["redirect"] is None
|
||||
assert r["projected"] == {"v": 1} and r["raw"] == {"v": 1} # bare body, not wrapped
|
||||
|
||||
|
||||
# --- 3. projecting the redirect location (strip a captured 3xx to just the URL) ----
|
||||
|
||||
async def test_projection_can_select_the_redirect_location():
|
||||
"""`redirect.location` must reach the redirect envelope and collapse the observation to just
|
||||
the target URL - so a 3xx can be stripped to the one thing the model needs, cheaply."""
|
||||
from forge.services.tools import ToolService
|
||||
|
||||
client = _redirecting_client("/shop/addproduct/PROD-002")
|
||||
cfg = _cfg(response={"projection_jmespath": "redirect.location"})
|
||||
rest_mod_select = rest_mod.select_client
|
||||
rest_mod.select_client = lambda *a, **k: client # type: ignore[assignment]
|
||||
try:
|
||||
r = await ToolService.test("t", "p", cfg, {})
|
||||
finally:
|
||||
rest_mod.select_client = rest_mod_select # type: ignore[assignment]
|
||||
await client.aclose()
|
||||
|
||||
assert r["ok"]
|
||||
assert r["projected"] == "/shop/addproduct/PROD-002" # just the URL, not the envelope
|
||||
assert 0 < r["projected_tokens"] < r["raw_tokens"] # stripped -> cheaper than the full envelope
|
||||
|
||||
|
||||
async def test_tool_returns_bare_redirect_location_to_the_agent_when_projected():
|
||||
"""The agent-facing observation (not just the preview) is the bare URL string."""
|
||||
client = _redirecting_client("/shop/addproduct/PROD-002")
|
||||
cfg = _cfg(response={"projection_jmespath": "redirect.location"})
|
||||
rest_mod_select = rest_mod.select_client
|
||||
rest_mod.select_client = lambda *a, **k: client # type: ignore[assignment]
|
||||
try:
|
||||
ctx = types.SimpleNamespace(tenant_id="t", project_id="p", auth_resolver=None, egress_policy=None)
|
||||
tool = build_rest_tool(cfg, ctx)
|
||||
out = await tool.ainvoke({})
|
||||
finally:
|
||||
rest_mod.select_client = rest_mod_select # type: ignore[assignment]
|
||||
await client.aclose()
|
||||
|
||||
assert out == "/shop/addproduct/PROD-002"
|
||||
|
||||
|
||||
async def test_no_redirect_returns_bare_body_unchanged():
|
||||
"""A normal 200 must behave exactly as before - no envelope, no redirect key."""
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, json={"v": 1})))
|
||||
res = await execute_rest(_cfg(), {}, tenant_id="t", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
|
||||
assert res["redirect"] is None
|
||||
from forge.tools.rest import _tool_return
|
||||
|
||||
assert _tool_return(res, _cfg()) == {"v": 1} # bare body, not wrapped
|
||||
|
||||
|
||||
# --- 2. SSRF-safe following ------------------------------------------------------
|
||||
|
||||
async def test_follow_resolves_final_url_and_chain():
|
||||
client = _redirecting_client("https://api.acme.dev/v2/final")
|
||||
cfg = _cfg()
|
||||
cfg["request"]["follow_redirects"] = True
|
||||
res = await execute_rest(cfg, {}, tenant_id="t", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
|
||||
assert res["status"] == 200 and res["raw"] == {"arrived": True, "path": "/v2/final"}
|
||||
assert res["redirect"]["followed"] is True
|
||||
assert res["redirect"]["final_url"] == "https://api.acme.dev/v2/final"
|
||||
assert res["redirect"]["chain"] == ["https://api.acme.dev/go"]
|
||||
|
||||
|
||||
async def test_follow_revalidates_each_hop_and_blocks_private_target():
|
||||
"""A redirect to a blocked address (cloud metadata) must be refused on the hop -
|
||||
the SSRF guard is not bypassed by following."""
|
||||
client = _redirecting_client("http://169.254.169.254/latest/meta-data/")
|
||||
cfg = _cfg()
|
||||
cfg["request"]["url_template"] = "https://8.8.8.8/go" # public literal so the first hop passes
|
||||
cfg["request"]["follow_redirects"] = True
|
||||
with pytest.raises(EgressBlocked):
|
||||
await execute_rest(cfg, {}, tenant_id="t", project_id="p", client=client,
|
||||
egress_policy=EgressPolicy(block_private=True))
|
||||
await client.aclose()
|
||||
|
||||
|
||||
def _auth_capturing_client(location: str) -> tuple[httpx.AsyncClient, dict]:
|
||||
"""Mock client that records the Authorization header seen at each hop."""
|
||||
seen: dict[str, str | None] = {}
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
seen[str(req.url)] = req.headers.get("authorization")
|
||||
if req.url.path == "/go":
|
||||
return httpx.Response(302, headers={"location": location})
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler)), seen
|
||||
|
||||
|
||||
async def test_follow_strips_authorization_on_cross_origin_redirect():
|
||||
"""SECURITY: a redirect to a DIFFERENT origin must NOT forward the tenant's
|
||||
Authorization header - otherwise following redirects exfiltrates credentials."""
|
||||
client, seen = _auth_capturing_client("https://evil.example/collect")
|
||||
cfg = _cfg()
|
||||
cfg["request"]["headers"] = [{"name": "Authorization", "value": "Bearer SECRET-TOKEN"}]
|
||||
cfg["request"]["follow_redirects"] = True
|
||||
await execute_rest(cfg, {}, tenant_id="t", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
|
||||
assert seen["https://api.acme.dev/go"] == "Bearer SECRET-TOKEN" # original origin keeps it
|
||||
assert seen["https://evil.example/collect"] is None # foreign origin must NOT receive it
|
||||
|
||||
|
||||
async def test_follow_preserves_authorization_on_same_origin_redirect():
|
||||
"""A same-origin redirect should keep the Authorization header (e.g. /v1 -> /v2)."""
|
||||
client, seen = _auth_capturing_client("https://api.acme.dev/v2/final")
|
||||
cfg = _cfg()
|
||||
cfg["request"]["headers"] = [{"name": "Authorization", "value": "Bearer SECRET-TOKEN"}]
|
||||
cfg["request"]["follow_redirects"] = True
|
||||
await execute_rest(cfg, {}, tenant_id="t", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
|
||||
assert seen["https://api.acme.dev/v2/final"] == "Bearer SECRET-TOKEN"
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Postgres RLS tenant-GUC wiring.
|
||||
|
||||
Verifies the request-scoped tenant contextvar round-trips (used by the GUC listener in
|
||||
forge.db.base to set app.current_tenant per transaction) and that the listener is a harmless
|
||||
no-op on the SQLite test DB, so ordinary queries keep working. The GUC's effect on real RLS
|
||||
policies is exercised only against Postgres (see infra/postgres_rls.sql)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.db.scoping import current_tenant, set_current_tenant, tenant_guard
|
||||
|
||||
|
||||
def test_tenant_contextvar_roundtrip():
|
||||
set_current_tenant(None)
|
||||
assert current_tenant() is None
|
||||
set_current_tenant("t-1")
|
||||
assert current_tenant() == "t-1"
|
||||
with tenant_guard("t-2"):
|
||||
assert current_tenant() == "t-2"
|
||||
assert current_tenant() == "t-1"
|
||||
set_current_tenant(None)
|
||||
|
||||
|
||||
async def test_sqlite_session_unaffected_by_guc():
|
||||
with tenant_guard("any-tenant"):
|
||||
async with SessionLocal() as s:
|
||||
assert (await s.execute(text("SELECT 1"))).scalar() == 1
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Per-run context injection (Feature: ephemeral per-run `run_context`).
|
||||
|
||||
A server-side caller passes an `X-Forge-Context` header on a run's EXECUTION request (stream /
|
||||
resume); its values reach tools as {{ctx.<key>}} for on-behalf-of injection (e.g. a per-user
|
||||
API key or tenant identifier). They are NEVER persisted, NEVER placed in the LLM prompt, NEVER an
|
||||
LLM-visible arg, and cannot be overridden by an LLM-supplied value.
|
||||
|
||||
Three lanes are kept distinct (the invariant these tests protect):
|
||||
- input parameters : `fields` with llm_visible=True -> the MODEL decides them (in args_schema)
|
||||
- injected context : {{ctx.*}} from run_context -> the SERVER injects them (not in schema)
|
||||
- auth providers : project-scoped stored secrets (covered elsewhere)
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from forge.deps import FORGE_CONTEXT_HEADER, run_context
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.tools.rest import build_args_schema, build_rest_tool, execute_rest
|
||||
|
||||
|
||||
def _capturing_client(sink: dict) -> httpx.AsyncClient:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
sink["request"] = request
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
# --- consumption: values reach the outbound request ---------------------------------------
|
||||
|
||||
|
||||
async def test_header_injection_from_context():
|
||||
sink: dict = {}
|
||||
cfg = {
|
||||
"name": "items_list",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url_template": "https://api.example.dev/items",
|
||||
"fields": [],
|
||||
"headers": [
|
||||
{"name": "Authorization", "value": "Bearer {{ctx.token}}"},
|
||||
{"name": "X-Tenant-Id", "value": "{{ctx.tenant}}"},
|
||||
],
|
||||
},
|
||||
}
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(
|
||||
cfg, {}, tenant_id="t", project_id="p",
|
||||
context={"token": "tok-abc", "tenant": "acme"}, client=client,
|
||||
)
|
||||
req = sink["request"]
|
||||
assert req.headers["authorization"] == "Bearer tok-abc"
|
||||
assert req.headers["x-tenant-id"] == "acme"
|
||||
|
||||
|
||||
async def test_body_field_injection_and_hidden_from_schema():
|
||||
"""A non-llm-visible field with a {{ctx.*}} default injects into the body AND is not exposed
|
||||
to the model (not in the tool args schema) - the input-vs-injected separation."""
|
||||
sink: dict = {}
|
||||
cfg = {
|
||||
"name": "create_item",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url_template": "https://api.example.dev/items",
|
||||
"fields": [
|
||||
{"path": "name", "type": "string", "in": "body", "required": True, "llm_visible": True},
|
||||
{"path": "api_key", "in": "body", "llm_visible": False, "default": "{{ctx.api_key}}"},
|
||||
],
|
||||
"headers": [],
|
||||
},
|
||||
}
|
||||
schema = build_args_schema(cfg)
|
||||
assert "name" in schema.model_fields # the model DOES decide this
|
||||
assert "api_key" not in schema.model_fields # the server injects this; model never sees it
|
||||
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(
|
||||
cfg, {"name": "widget"}, tenant_id="t", project_id="p",
|
||||
context={"api_key": "sk-123"}, client=client,
|
||||
)
|
||||
assert json.loads(sink["request"].content) == {"name": "widget", "api_key": "sk-123"}
|
||||
|
||||
|
||||
async def test_query_field_injection():
|
||||
sink: dict = {}
|
||||
cfg = {
|
||||
"name": "search_items",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url_template": "https://api.example.dev/items/search",
|
||||
"fields": [{"path": "api_key", "in": "query", "llm_visible": False, "default": "{{ctx.api_key}}"}],
|
||||
"headers": [],
|
||||
},
|
||||
}
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(cfg, {}, tenant_id="t", project_id="p", context={"api_key": "sk-123"}, client=client)
|
||||
assert sink["request"].url.params["api_key"] == "sk-123"
|
||||
|
||||
|
||||
async def test_url_template_ctx_injection():
|
||||
"""{{ctx.*}} is honored directly in the URL/query string too."""
|
||||
sink: dict = {}
|
||||
cfg = {
|
||||
"name": "x",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url_template": "https://api.example.dev/items?token={{ctx.token}}",
|
||||
"fields": [],
|
||||
"headers": [],
|
||||
},
|
||||
}
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(cfg, {}, tenant_id="t", project_id="p", context={"token": "tok-abc"}, client=client)
|
||||
assert sink["request"].url.params["token"] == "tok-abc"
|
||||
|
||||
|
||||
async def test_cookie_field_injection():
|
||||
sink: dict = {}
|
||||
cfg = {
|
||||
"name": "x",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url_template": "https://api.example.dev/x",
|
||||
"fields": [{"path": "sid", "in": "cookie", "llm_visible": False, "default": "{{ctx.session}}"}],
|
||||
"headers": [],
|
||||
},
|
||||
}
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(cfg, {}, tenant_id="t", project_id="p", context={"session": "s-xyz"}, client=client)
|
||||
assert "sid=s-xyz" in sink["request"].headers.get("cookie", "")
|
||||
|
||||
|
||||
async def test_body_template_json_with_input_and_ctx():
|
||||
"""A free-form JSON body template mixes {{input.*}} (model args) and {{ctx.*}} (injected)."""
|
||||
sink: dict = {}
|
||||
cfg = {
|
||||
"name": "create_item",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url_template": "https://api.example.dev/items",
|
||||
"fields": [{"path": "amount", "type": "integer", "in": "body", "llm_visible": True}],
|
||||
"headers": [],
|
||||
"body_template": '{"amount": {{ input.amount }}, "api_key": "{{ ctx.api_key }}"}',
|
||||
},
|
||||
}
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(cfg, {"amount": 5}, tenant_id="t", project_id="p", context={"api_key": "sk-123"}, client=client)
|
||||
assert json.loads(sink["request"].content) == {"amount": 5, "api_key": "sk-123"}
|
||||
|
||||
|
||||
async def test_body_template_non_json_sent_raw():
|
||||
"""A body template that isn't JSON is sent as raw bytes unchanged."""
|
||||
sink: dict = {}
|
||||
cfg = {
|
||||
"name": "x",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url_template": "https://api.example.dev/x",
|
||||
"fields": [],
|
||||
"headers": [],
|
||||
"body_template": "token={{ctx.token}}&scope=all",
|
||||
},
|
||||
}
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(cfg, {}, tenant_id="t", project_id="p", context={"token": "tok-abc"}, client=client)
|
||||
assert sink["request"].content == b"token=tok-abc&scope=all"
|
||||
|
||||
|
||||
# --- security invariants -------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_ctx_header_is_authoritative_over_llm_field():
|
||||
"""An LLM-supplied header field must NOT override a server-injected ctx-templated header."""
|
||||
sink: dict = {}
|
||||
cfg = {
|
||||
"name": "x",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url_template": "https://api.example.dev/x",
|
||||
"fields": [{"path": "X-Api-Key", "type": "string", "in": "header", "llm_visible": True}],
|
||||
"headers": [{"name": "X-Api-Key", "value": "{{ctx.api_key}}"}],
|
||||
},
|
||||
}
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(
|
||||
cfg, {"X-Api-Key": "attacker-supplied"}, tenant_id="t", project_id="p",
|
||||
context={"api_key": "sk-123"}, client=client,
|
||||
)
|
||||
assert sink["request"].headers["x-api-key"] == "sk-123"
|
||||
|
||||
|
||||
async def test_missing_context_value_is_dropped_not_literal():
|
||||
"""A {{ctx.*}} default with no matching context value is omitted - never sent as the raw
|
||||
template string (regression for the _collect raw-default fallback)."""
|
||||
sink: dict = {}
|
||||
cfg = {
|
||||
"name": "x",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url_template": "https://api.example.dev/x",
|
||||
"fields": [{"path": "api_key", "in": "body", "llm_visible": False, "default": "{{ctx.api_key}}"}],
|
||||
"headers": [],
|
||||
},
|
||||
}
|
||||
async with _capturing_client(sink) as client:
|
||||
await execute_rest(cfg, {}, tenant_id="t", project_id="p", context={}, client=client)
|
||||
# No body was sent (the only field resolved to None and was dropped, not sent literally).
|
||||
assert sink["request"].content in (b"", b"null")
|
||||
|
||||
|
||||
async def test_run_context_and_end_user_are_distinct_lanes():
|
||||
"""Via build_rest_tool: ctx.run_context supplies {{ctx.api_key}} while ctx.end_user supplies
|
||||
{{ctx.end_user.id}} - both resolve, and the secret is not part of end_user (identity)."""
|
||||
sink: dict = {}
|
||||
cfg = {
|
||||
"name": "on_behalf",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url_template": "https://api.example.dev/me",
|
||||
"fields": [],
|
||||
"headers": [
|
||||
{"name": "X-Api-Key", "value": "{{ctx.api_key}}"},
|
||||
{"name": "X-User", "value": "{{ctx.end_user.id}}"},
|
||||
],
|
||||
},
|
||||
}
|
||||
ctx = CompileContext(tenant_id="t", project_id="p")
|
||||
ctx.run_context = {"api_key": "sk-123"}
|
||||
ctx.end_user = {"id": "u1"}
|
||||
|
||||
class _RT:
|
||||
context: dict = {}
|
||||
stream_writer = None
|
||||
|
||||
tool = build_rest_tool(cfg, ctx)
|
||||
async with _capturing_client(sink) as client:
|
||||
# execute_rest picks its client via select_client; point that at ours.
|
||||
import forge.tools.rest as rest_mod
|
||||
|
||||
orig = rest_mod.select_client
|
||||
rest_mod.select_client = lambda *a, **k: client # type: ignore[assignment]
|
||||
try:
|
||||
await tool.coroutine(runtime=_RT())
|
||||
finally:
|
||||
rest_mod.select_client = orig # type: ignore[assignment]
|
||||
|
||||
req = sink["request"]
|
||||
assert req.headers["x-api-key"] == "sk-123"
|
||||
assert req.headers["x-user"] == "u1"
|
||||
# The secret is NOT part of identity (so it never reaches the end_user prompt block).
|
||||
assert "api_key" not in ctx.end_user
|
||||
|
||||
|
||||
# --- transport: header parsing -------------------------------------------------------------
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, headers: dict):
|
||||
self.headers = headers
|
||||
|
||||
|
||||
def test_run_context_parses_json_object():
|
||||
r = _Req({FORGE_CONTEXT_HEADER: '{"token": "tok-abc", "tenant": "acme"}'})
|
||||
assert run_context(r) == {"token": "tok-abc", "tenant": "acme"}
|
||||
|
||||
|
||||
def test_run_context_absent_is_none():
|
||||
assert run_context(_Req({})) is None
|
||||
|
||||
|
||||
def test_run_context_strips_end_user():
|
||||
r = _Req({FORGE_CONTEXT_HEADER: '{"end_user": {"id": "x"}, "token": "tok-abc"}'})
|
||||
assert run_context(r) == {"token": "tok-abc"} # identity is not settable via this channel
|
||||
|
||||
|
||||
def test_run_context_rejects_invalid_json():
|
||||
with pytest.raises(HTTPException) as e:
|
||||
run_context(_Req({FORGE_CONTEXT_HEADER: "not-json"}))
|
||||
assert e.value.status_code == 400
|
||||
|
||||
|
||||
def test_run_context_rejects_non_object():
|
||||
with pytest.raises(HTTPException) as e:
|
||||
run_context(_Req({FORGE_CONTEXT_HEADER: '"just-a-string"'}))
|
||||
assert e.value.status_code == 400
|
||||
|
||||
|
||||
def test_run_context_rejects_oversized():
|
||||
big = json.dumps({"x": "a" * 9000})
|
||||
with pytest.raises(HTTPException) as e:
|
||||
run_context(_Req({FORGE_CONTEXT_HEADER: big}))
|
||||
assert e.value.status_code == 413
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Secrets, auth resolver (csrf/bearer), REST tool + projection, and builtin /test validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from forge.auth_providers.resolver import AuthResolver
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import AuditLog, AuthProvider
|
||||
from forge.secrets.store import SecretStore
|
||||
from forge.services.tools import ToolService
|
||||
from forge.tools.rest import build_args_schema, execute_rest
|
||||
|
||||
GET_ORDER = {
|
||||
"name": "get_order",
|
||||
"description": "Fetch an order.",
|
||||
"kind": "rest_api",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url_template": "https://api.acme.dev/v2/orders/{order_id}",
|
||||
"fields": [
|
||||
{"path": "order_id", "type": "string", "in": "path", "required": True, "llm_visible": True},
|
||||
{"path": "include", "type": "string", "in": "query", "required": False, "llm_visible": False, "default": "totals"},
|
||||
],
|
||||
"headers": [{"name": "Accept", "value": "application/json"}],
|
||||
},
|
||||
"response": {"projection_jmespath": "data.{subtotal: totals.subtotal, total: totals.grand_total, status: status}"},
|
||||
}
|
||||
|
||||
|
||||
# --- secrets ---
|
||||
async def test_secret_roundtrip_encrypts_and_decrypts():
|
||||
store = SecretStore()
|
||||
async with SessionLocal() as s:
|
||||
await store.write(s, tenant_id="t_sec", project_id="p_sec", name="creds", value={"u": "a", "p": "b"}, kind="generic")
|
||||
got = await store.read_ref(tenant_id="t_sec", project_id="p_sec", ref="secret://proj/creds")
|
||||
assert got == {"u": "a", "p": "b"}
|
||||
async with SessionLocal() as s:
|
||||
audit = (
|
||||
await s.execute(
|
||||
select(AuditLog).where(
|
||||
AuditLog.tenant_id == "t_sec",
|
||||
AuditLog.project_id == "p_sec",
|
||||
AuditLog.action == "secret.read",
|
||||
AuditLog.resource_type == "secret",
|
||||
AuditLog.resource_id == "creds",
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
assert audit.meta == {"scheme": "secret"}
|
||||
|
||||
|
||||
# --- REST tool ---
|
||||
def test_args_schema_excludes_non_llm_visible_fields():
|
||||
Args = build_args_schema(GET_ORDER)
|
||||
assert set(Args.model_fields) == {"order_id"} # `include` is hidden from the model
|
||||
|
||||
|
||||
async def test_rest_execute_projects_payload():
|
||||
seen = {}
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(req.url)
|
||||
return httpx.Response(200, json={
|
||||
"data": {"totals": {"subtotal": 90, "grand_total": 99}, "line_items": [1, 2, 3, 4], "status": "open"},
|
||||
})
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
res = await execute_rest(GET_ORDER, {"order_id": "A-1"}, tenant_id="t", project_id="p", context={}, auth_resolver=None, client=client)
|
||||
await client.aclose()
|
||||
|
||||
assert "/orders/A-1" in seen["url"] and "include=totals" in seen["url"]
|
||||
assert res["projected"] == {"subtotal": 90, "total": 99, "status": "open"}
|
||||
assert res["raw"]["data"]["line_items"] == [1, 2, 3, 4] # raw keeps everything
|
||||
|
||||
|
||||
# --- auth resolver ---
|
||||
async def test_csrf_session_extract_and_inject():
|
||||
ap = AuthProvider(
|
||||
id="ap1", tenant_id="t", project_id="p", name="orders", kind="csrf_session",
|
||||
config={
|
||||
"kind": "csrf_session",
|
||||
"token_fetch": {"method": "POST", "url": "https://api.acme.dev/auth/login", "body": {}},
|
||||
"extract": [
|
||||
{"name": "csrf", "from": "header", "header": "X-CSRF-Token"},
|
||||
{"name": "session", "from": "cookie", "cookie": "SESSIONID"},
|
||||
],
|
||||
"inject": [
|
||||
{"to": "header", "name": "X-CSRF-Token", "value": "{{extracted.csrf}}"},
|
||||
{"to": "cookie", "name": "SESSIONID", "value": "{{extracted.session}}"},
|
||||
],
|
||||
"cache_ttl_seconds": 1800,
|
||||
},
|
||||
)
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, headers=[("X-CSRF-Token", "abc123"), ("set-cookie", "SESSIONID=zzz; Path=/")])
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
resolved = await AuthResolver().resolve(tenant_id="t", project_id="p", provider_id="ap1", provider=ap, client=client, force=True)
|
||||
await client.aclose()
|
||||
assert resolved.headers["X-CSRF-Token"] == "abc123"
|
||||
assert resolved.cookies["SESSIONID"] == "zzz"
|
||||
|
||||
|
||||
async def test_bearer_static_auth():
|
||||
store = SecretStore()
|
||||
async with SessionLocal() as s:
|
||||
await store.write(s, tenant_id="t", project_id="p", name="tok", value="T0KEN", kind="bearer")
|
||||
ap = AuthProvider(id="b1", tenant_id="t", project_id="p", name="b", kind="bearer", config={"kind": "bearer", "token_ref": "secret://proj/tok"})
|
||||
resolved = await AuthResolver().resolve(tenant_id="t", project_id="p", provider_id="b1", provider=ap, force=True)
|
||||
assert resolved.headers["Authorization"] == "Bearer T0KEN"
|
||||
|
||||
|
||||
# --- builtin tool /test ---
|
||||
def test_resolve_model_injects_project_provider_key(monkeypatch):
|
||||
"""A per-project key in ctx.provider_credentials is passed to init_chat_model."""
|
||||
import langchain.chat_models as cm
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def fake_init(model, **kwargs):
|
||||
captured["model"] = model
|
||||
captured.update(kwargs)
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(cm, "init_chat_model", fake_init)
|
||||
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.engine.models import resolve_model
|
||||
|
||||
ctx = CompileContext(tenant_id="t", project_id="p", provider_credentials={"openai": "sk-proj-test"})
|
||||
resolve_model("openai:gpt-5.4-mini", ctx)
|
||||
assert captured["model"] == "openai:gpt-5.4-mini"
|
||||
assert captured.get("api_key") == "sk-proj-test"
|
||||
|
||||
# Google uses google_api_key, not api_key.
|
||||
captured.clear()
|
||||
ctx2 = CompileContext(tenant_id="t", project_id="p", provider_credentials={"google_genai": "g-key"})
|
||||
resolve_model("google_genai:gemini-3.5-flash", ctx2)
|
||||
assert captured.get("google_api_key") == "g-key"
|
||||
|
||||
|
||||
async def test_calculator_builtin_test_endpoint_logic():
|
||||
res = await ToolService.test("t", "p", {"name": "calc", "kind": "builtin", "builtin": "calculator", "description": "d"}, {"expression": "2*(3+4)"})
|
||||
assert res["ok"] is True
|
||||
assert res["projected"] == "14"
|
||||
|
||||
|
||||
# --- tools wired into an agent ---
|
||||
_CALC = {"name": "calculator", "kind": "builtin", "builtin": "calculator", "description": "Evaluate arithmetic."}
|
||||
|
||||
|
||||
async def test_agent_compiles_and_runs_with_bound_tool():
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from forge.engine.compiler import compile_workflow
|
||||
from forge.services.runtime import make_runtime_ctx
|
||||
from forge.tools.materialize import materialize_tool
|
||||
|
||||
ctx = make_runtime_ctx("t", "p")
|
||||
ctx.checkpointer = InMemorySaver()
|
||||
ctx.tool_registry = {"tool_calc": materialize_tool(_CALC, ctx)}
|
||||
wf = {
|
||||
"id": "wf_tool", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "agent",
|
||||
"nodes": [
|
||||
{"id": "agent", "type": "agent", "config": {"flavor": "agent", "model": "fake:Done.", "tools": ["tool_calc"]}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "agent", "target": "end"}],
|
||||
}
|
||||
graph = compile_workflow(wf, ctx)
|
||||
out = await graph.ainvoke({"messages": [HumanMessage(content="hi")]}, {"configurable": {"thread_id": "t1"}})
|
||||
assert out["messages"][-1].content == "Done."
|
||||
|
||||
|
||||
async def test_agent_actually_invokes_tool_full_loop():
|
||||
"""Scripted fake model emits a tool call → calculator runs → 6*7 = 42 appears."""
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from forge.services.runtime import make_runtime_ctx
|
||||
from forge.tools.materialize import materialize_tool
|
||||
|
||||
class Fake(GenericFakeChatModel):
|
||||
def bind_tools(self, tools=None, **kwargs):
|
||||
return self
|
||||
|
||||
scripted = iter([
|
||||
AIMessage(content="", tool_calls=[{"name": "calculator", "args": {"expression": "6*7"}, "id": "c1", "type": "tool_call"}]),
|
||||
AIMessage(content="It is 42."),
|
||||
])
|
||||
tool = materialize_tool(_CALC, make_runtime_ctx("t", "p"))
|
||||
agent = create_agent(model=Fake(messages=scripted), tools=[tool], system_prompt="Use the calculator.")
|
||||
out = await agent.ainvoke({"messages": [HumanMessage(content="what is 6*7")]})
|
||||
tool_msgs = [m for m in out["messages"] if getattr(m, "type", None) == "tool"]
|
||||
assert any("42" in str(m.content) for m in tool_msgs), [m.content for m in out["messages"]]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Security + correctness hardening regression tests.
|
||||
|
||||
Covers high-risk behaviors: embed/run project scoping, anonymous thread-identity
|
||||
isolation, atomic quota admission, the stale-run reaper, the resume-state guard,
|
||||
JWT revocation, and component tenant/project scoping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import Run, Tenant, Workflow
|
||||
from forge.services.components import ComponentService
|
||||
from forge.services.runs import RunService
|
||||
|
||||
# --- S1: runs are scoped by project, not just tenant -------------------------------------
|
||||
|
||||
async def test_stream_is_scoped_by_project():
|
||||
async with SessionLocal() as s:
|
||||
run = Run(tenant_id="t1", project_id="pA", workflow_id="w", thread_id="th", status="queued", input={})
|
||||
s.add(run)
|
||||
await s.commit()
|
||||
rid = run.id
|
||||
rs = RunService(checkpointer=None)
|
||||
# Streaming the run under a DIFFERENT project (same tenant) must not find it.
|
||||
first = None
|
||||
async for frame in rs.stream(run_id=rid, tenant_id="t1", project_id="pB", public=True):
|
||||
first = frame
|
||||
break
|
||||
assert first["event"] == "error" and "not found" in first["data"]["message"]
|
||||
|
||||
|
||||
async def test_resume_is_scoped_by_project():
|
||||
async with SessionLocal() as s:
|
||||
run = Run(tenant_id="t1", project_id="pA", workflow_id="w", thread_id="th", status="interrupted", input={})
|
||||
s.add(run)
|
||||
await s.commit()
|
||||
rid = run.id
|
||||
rs = RunService(checkpointer=None)
|
||||
res = await rs.resume(run_id=rid, tenant_id="t1", value=True, project_id="pB")
|
||||
assert res.get("error") == "run not found"
|
||||
|
||||
|
||||
# --- S3: an anonymous caller can't attach to a thread bound to another identity ----------
|
||||
|
||||
async def test_create_run_isolates_foreign_identity_threads():
|
||||
async with SessionLocal() as s:
|
||||
wf = Workflow(tenant_id="t2", project_id="p", name="w", executable={}, status="active")
|
||||
s.add(wf)
|
||||
await s.commit()
|
||||
wid = wf.id
|
||||
rs = RunService()
|
||||
async with SessionLocal() as s:
|
||||
r1 = await rs.create_run(s, tenant_id="t2", project_id="p", workflow_id=wid, input={}, end_user={"id": "alice"})
|
||||
thread_alice = r1.thread_id
|
||||
# Anonymous caller supplies alice's thread_id -> must start a FRESH thread, not attach.
|
||||
async with SessionLocal() as s:
|
||||
r2 = await rs.create_run(s, tenant_id="t2", project_id="p", workflow_id=wid, input={}, thread_id=thread_alice, end_user=None)
|
||||
assert r2.thread_id != thread_alice
|
||||
# The same identity CAN continue its own thread.
|
||||
async with SessionLocal() as s:
|
||||
r3 = await rs.create_run(s, tenant_id="t2", project_id="p", workflow_id=wid, input={}, thread_id=thread_alice, end_user={"id": "alice"})
|
||||
assert r3.thread_id == thread_alice
|
||||
|
||||
|
||||
# --- F2 / S2: quota admission is enforced (and atomic) -----------------------------------
|
||||
|
||||
async def test_run_admission_enforces_quota():
|
||||
from forge.services.quota import QuotaExceeded, run_admission
|
||||
|
||||
async with SessionLocal() as s:
|
||||
t = Tenant(name="QA", settings={"max_runs_per_day": 1})
|
||||
s.add(t)
|
||||
await s.flush()
|
||||
s.add(Run(tenant_id=t.id, project_id="p", workflow_id="w", thread_id="th", status="done"))
|
||||
await s.commit()
|
||||
tid = t.id
|
||||
async with SessionLocal() as s:
|
||||
with pytest.raises(QuotaExceeded):
|
||||
async with run_admission(s, tid):
|
||||
pass
|
||||
|
||||
|
||||
async def test_quota_ignores_errored_runs():
|
||||
from forge.services.quota import check_run_quota
|
||||
|
||||
async with SessionLocal() as s:
|
||||
t = Tenant(name="QE", settings={"max_runs_per_day": 1})
|
||||
s.add(t)
|
||||
await s.flush()
|
||||
# An errored run must not consume the daily allowance.
|
||||
s.add(Run(tenant_id=t.id, project_id="p", workflow_id="w", thread_id="th", status="error"))
|
||||
await s.commit()
|
||||
await check_run_quota(s, t.id) # must NOT raise
|
||||
|
||||
|
||||
# --- F3: the reaper resolves stale queued/running runs -----------------------------------
|
||||
|
||||
async def test_reaper_marks_stale_runs():
|
||||
old = datetime.utcnow() - timedelta(hours=3)
|
||||
async with SessionLocal() as s:
|
||||
q = Run(tenant_id="t3", project_id="p", workflow_id="w", thread_id="th", status="queued", input={})
|
||||
r = Run(tenant_id="t3", project_id="p", workflow_id="w", thread_id="th", status="running", input={})
|
||||
s.add_all([q, r])
|
||||
await s.flush()
|
||||
q.created_at = old
|
||||
r.started_at = old
|
||||
await s.commit()
|
||||
qid, rid = q.id, r.id
|
||||
reaped = await RunService().reap_stale_runs(queued_max_age_s=60, running_max_age_s=60)
|
||||
assert reaped >= 2
|
||||
async with SessionLocal() as s:
|
||||
assert (await s.get(Run, qid)).status == "error"
|
||||
assert (await s.get(Run, rid)).status == "error"
|
||||
|
||||
|
||||
# --- F-low: resume only an interrupted run -----------------------------------------------
|
||||
|
||||
async def test_resume_rejects_non_interrupted_run():
|
||||
async with SessionLocal() as s:
|
||||
run = Run(tenant_id="t4", project_id="p", workflow_id="w", thread_id="th", status="done", input={})
|
||||
s.add(run)
|
||||
await s.commit()
|
||||
rid = run.id
|
||||
res = await RunService(checkpointer=None).resume(run_id=rid, tenant_id="t4", value=True)
|
||||
assert res.get("error") and "not awaiting input" in res["error"]
|
||||
|
||||
|
||||
# --- S11: identity (session) tokens can be revoked ---------------------------------------
|
||||
|
||||
def test_session_token_roundtrip_and_revocation():
|
||||
from forge.security import TokenError, create_session_token, decode_token, revoke
|
||||
|
||||
tok = create_session_token(tenant_id="t", project_id="p", end_user={"id": "u"})
|
||||
claims = decode_token(tok, expected_type="session")
|
||||
assert claims["jti"] and claims["end_user"]["id"] == "u"
|
||||
revoke(claims["jti"])
|
||||
with pytest.raises(TokenError):
|
||||
decode_token(tok, expected_type="session")
|
||||
|
||||
|
||||
# --- L2 / component isolation: get is scoped by project ----------------------------------
|
||||
|
||||
async def test_component_get_is_project_scoped():
|
||||
async with SessionLocal() as s:
|
||||
comp = await ComponentService.create(s, "tc", "projA", name="card", html="<div>{{x}}</div>")
|
||||
cid = comp.id
|
||||
async with SessionLocal() as s:
|
||||
assert await ComponentService.get(s, "tc", "projA", cid) is not None
|
||||
# Same tenant, wrong project -> not found (no cross-project read).
|
||||
assert await ComponentService.get(s, "tc", "projB", cid) is None
|
||||
|
||||
|
||||
# --- S7: the SQL-tool DSN guard honors the per-project EgressPolicy instance --------------
|
||||
|
||||
async def test_sql_tool_honors_project_egress_policy():
|
||||
from forge.tools.sql import execute_sql
|
||||
from forge.util.ssrf import EgressBlocked, EgressPolicy
|
||||
|
||||
cfg = {"name": "q", "query": "SELECT 1",
|
||||
"connection_url": "postgresql+asyncpg://u:p@blocked.example.com:5432/db"}
|
||||
# A resolved EgressPolicy INSTANCE (what ctx.egress_policy is) must be applied directly -
|
||||
# not silently rebuilt from global settings (which, in tests, block nothing).
|
||||
policy = EgressPolicy(block_private=False, deny_hosts=("blocked.example.com",))
|
||||
with pytest.raises(EgressBlocked):
|
||||
await execute_sql(cfg, {}, tenant_id="t", project_id="p", egress=policy)
|
||||
|
||||
|
||||
# --- S4: a non-editor cannot self-assert privileged identity via the run body ------------
|
||||
|
||||
def test_role_gate_blocks_viewer_entitlements():
|
||||
from forge.services.auth import role_at_least
|
||||
|
||||
# The create_run route strips roles/entitlements unless the caller is editor+.
|
||||
assert role_at_least("editor", "editor") is True
|
||||
assert role_at_least("viewer", "editor") is False
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Semantic response cache: paraphrased questions hit the cache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.services.semantic_cache import SemanticCacheService
|
||||
|
||||
T, P = "t_sc", "p_sc"
|
||||
|
||||
|
||||
async def test_store_then_lookup_hits_on_paraphrase():
|
||||
async with SessionLocal() as s:
|
||||
await SemanticCacheService.store(s, T, P, "What are your business hours?", "We're open 9am-5pm ET.")
|
||||
async with SessionLocal() as s:
|
||||
# near-duplicate question; fake embedder gives high overlap on shared words
|
||||
hit = await SemanticCacheService.lookup(s, T, P, "what are your business hours", threshold=0.6)
|
||||
assert hit == "We're open 9am-5pm ET."
|
||||
|
||||
|
||||
async def test_lookup_miss_below_threshold():
|
||||
async with SessionLocal() as s:
|
||||
await SemanticCacheService.store(s, "t_m", "p_m", "How do I reset my password?", "Use the reset link.")
|
||||
async with SessionLocal() as s:
|
||||
hit = await SemanticCacheService.lookup(s, "t_m", "p_m", "completely unrelated rocket science", threshold=0.9)
|
||||
assert hit is None
|
||||
|
||||
|
||||
async def test_ttl_expiry():
|
||||
async with SessionLocal() as s:
|
||||
await SemanticCacheService.store(s, "t_t", "p_t", "ping?", "pong")
|
||||
async with SessionLocal() as s:
|
||||
assert await SemanticCacheService.lookup(s, "t_t", "p_t", "ping?", threshold=0.5, ttl=-1) is None
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Server-to-server integration primitives:
|
||||
|
||||
1. A static service API token (`FORGE_SERVICE_API_TOKEN`) that authenticates a trusted backend
|
||||
as a least-privilege (editor) service identity - the outer "is this call from our backend"
|
||||
barrier. Non-expiring, revoked by rotation.
|
||||
2. An egress allow-private-hosts toggle (`FORGE_EGRESS_ALLOW_PRIVATE_HOSTS`) that lets specific
|
||||
trusted internal hosts (localhost, on-prem services) be reached even while the SSRF guard's
|
||||
private-address block stays on globally (default-deny, explicit-allow).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from forge.config import settings
|
||||
from forge.deps import get_current_user
|
||||
from forge.util.ssrf import EgressBlocked, EgressPolicy, validate_url
|
||||
|
||||
# --- service token ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _State:
|
||||
tenant_id = "t-seed"
|
||||
|
||||
|
||||
class _App:
|
||||
state = _State()
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, authorization: str | None):
|
||||
self.headers = {"authorization": authorization} if authorization else {}
|
||||
self.app = _App()
|
||||
|
||||
|
||||
async def test_service_token_authenticates_as_editor(monkeypatch):
|
||||
monkeypatch.setattr(settings, "service_api_token", "svc-secret-abc123")
|
||||
user = await get_current_user(_Req("Bearer svc-secret-abc123"))
|
||||
assert user.role == "editor" # least privilege, but enough to assert end_user identity
|
||||
assert user.tenant_id == "t-seed" # bound to the seeded workspace
|
||||
assert user.is_fallback is False
|
||||
|
||||
|
||||
async def test_wrong_service_token_is_rejected(monkeypatch):
|
||||
# A present-but-wrong token must NOT fall back to an anonymous/owner identity: it fails the
|
||||
# constant-time compare, then fails JWT decode -> 401.
|
||||
monkeypatch.setattr(settings, "service_api_token", "svc-secret-abc123")
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await get_current_user(_Req("Bearer not-the-token"))
|
||||
assert e.value.status_code == 401
|
||||
|
||||
|
||||
async def test_empty_service_token_setting_is_disabled(monkeypatch):
|
||||
# With no service token configured, the branch is inert (a random bearer is treated as a
|
||||
# would-be JWT and rejected).
|
||||
monkeypatch.setattr(settings, "service_api_token", "")
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await get_current_user(_Req("Bearer anything"))
|
||||
assert e.value.status_code == 401
|
||||
|
||||
|
||||
# --- egress allow-private-hosts --------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_private_host_blocked_by_default():
|
||||
with pytest.raises(EgressBlocked):
|
||||
await validate_url("http://127.0.0.1:9002/x", EgressPolicy(block_private=True))
|
||||
|
||||
|
||||
async def test_allow_private_hosts_bypasses_block_by_ip():
|
||||
url = await validate_url(
|
||||
"http://127.0.0.1:9002/x",
|
||||
EgressPolicy(block_private=True, allow_private_hosts=("127.0.0.1",)),
|
||||
)
|
||||
assert url == "http://127.0.0.1:9002/x"
|
||||
|
||||
|
||||
async def test_allow_private_hosts_bypasses_block_by_name():
|
||||
# `localhost` resolves to a loopback IP, but the host is explicitly allow-listed.
|
||||
url = await validate_url(
|
||||
"http://localhost:9002/orders/detail/1",
|
||||
EgressPolicy(block_private=True, allow_private_hosts=("localhost",)),
|
||||
)
|
||||
assert url.startswith("http://localhost")
|
||||
|
||||
|
||||
async def test_non_allowlisted_private_still_blocked():
|
||||
with pytest.raises(EgressBlocked):
|
||||
await validate_url(
|
||||
"http://10.0.0.5/x",
|
||||
EgressPolicy(block_private=True, allow_private_hosts=("localhost",)),
|
||||
)
|
||||
|
||||
|
||||
def test_from_settings_reads_global_allow_private(monkeypatch):
|
||||
monkeypatch.setattr(settings, "egress_allow_private_hosts", ["localhost", "127.0.0.1"])
|
||||
p = EgressPolicy.from_settings()
|
||||
assert "localhost" in p.allow_private_hosts and "127.0.0.1" in p.allow_private_hosts
|
||||
|
||||
|
||||
def test_from_settings_ignores_project_allow_private(monkeypatch):
|
||||
# SECURITY (audit H1): project config is editable by any tenant member, so it must NOT be able
|
||||
# to add a private-host bypass. allow_private_hosts is a deployment-level control only.
|
||||
monkeypatch.setattr(settings, "egress_allow_private_hosts", [])
|
||||
p = EgressPolicy.from_settings({"allow_private_hosts": ["10.0.0.5"]})
|
||||
assert "10.0.0.5" not in p.allow_private_hosts
|
||||
assert p.allow_private_hosts == ()
|
||||
|
||||
|
||||
def test_from_settings_project_cannot_loosen_block_private(monkeypatch):
|
||||
# SECURITY (audit H1): a project may only TIGHTEN block_private (False->True), never turn the
|
||||
# SSRF guard off. With the guard on globally, project block_private:false is ignored.
|
||||
monkeypatch.setattr(settings, "egress_block_private", True)
|
||||
assert EgressPolicy.from_settings({"block_private": False}).block_private is True
|
||||
# And a project can still tighten when the deployment default is off.
|
||||
monkeypatch.setattr(settings, "egress_block_private", False)
|
||||
assert EgressPolicy.from_settings({"block_private": True}).block_private is True
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Durable SSE (finding #12): run execution is decoupled from the client connection.
|
||||
|
||||
A mid-run client disconnect must NOT end the run - the graph keeps executing in a detached
|
||||
background task, and a client can reattach (same run_id) to get the final answer. Frames carry
|
||||
a monotonic id so a `Last-Event-ID` reconnect replays only what was missed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import Run, Thread, Workflow
|
||||
from forge.services.runs import RunService
|
||||
|
||||
_ANSWER = "Hello from the durable run."
|
||||
_WF = {
|
||||
"id": "wf_sse", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "agent",
|
||||
"nodes": [
|
||||
{"id": "agent", "type": "agent", "config": {"flavor": "agent", "model": f"fake:{_ANSWER}", "tools": []}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "agent", "target": "end"}],
|
||||
}
|
||||
|
||||
|
||||
async def _seed_queued_run() -> tuple[str, str, str]:
|
||||
"""Insert a workflow + thread + a queued run directly; returns (tenant, project, run_id)."""
|
||||
t, p = f"t_{uuid.uuid4().hex[:8]}", f"p_{uuid.uuid4().hex[:8]}"
|
||||
async with SessionLocal() as s:
|
||||
wf = Workflow(tenant_id=t, project_id=p, name="w", executable=_WF, status="active")
|
||||
s.add(wf)
|
||||
await s.flush()
|
||||
thread = Thread(tenant_id=t, project_id=p, workflow_id=wf.id, lg_thread_id=f"lg_{uuid.uuid4().hex}", meta={})
|
||||
s.add(thread)
|
||||
await s.flush()
|
||||
run = Run(tenant_id=t, project_id=p, workflow_id=wf.id, thread_id=thread.id, status="queued",
|
||||
input={"messages": [{"role": "user", "content": "hi"}]})
|
||||
s.add(run)
|
||||
await s.commit()
|
||||
return t, p, run.id
|
||||
|
||||
|
||||
async def test_disconnect_leaves_run_running_and_reattach_gets_answer():
|
||||
t, p, rid = await _seed_queued_run()
|
||||
rs = RunService(checkpointer=InMemorySaver())
|
||||
|
||||
# First connection: take a single frame, then "disconnect" by closing the subscriber.
|
||||
agen = rs.stream(run_id=rid, tenant_id=t, project_id=p)
|
||||
first = await agen.__anext__()
|
||||
assert first["event"] == "run" and first["id"] == "1"
|
||||
await agen.aclose() # client gone - the detached executor must keep running
|
||||
|
||||
# Reattach from where we left off; the run completes and we get the final answer.
|
||||
frames = [f async for f in rs.stream(run_id=rid, tenant_id=t, project_id=p, last_event_id=int(first["id"]))]
|
||||
done = [f for f in frames if f["event"] == "done"]
|
||||
assert done, [f["event"] for f in frames]
|
||||
assert _ANSWER in (done[-1]["data"].get("answer") or "")
|
||||
|
||||
# The disconnect did NOT cancel the run - it ran to completion.
|
||||
async with SessionLocal() as s:
|
||||
run = await s.get(Run, rid)
|
||||
assert run.status == "done", run.status
|
||||
|
||||
|
||||
async def test_last_event_id_replays_only_later_frames():
|
||||
t, p, rid = await _seed_queued_run()
|
||||
rs = RunService(checkpointer=InMemorySaver())
|
||||
|
||||
# Drive the run to completion on the first connection, recording every frame id.
|
||||
frames = [f async for f in rs.stream(run_id=rid, tenant_id=t, project_id=p)]
|
||||
ids = [int(f["id"]) for f in frames if f.get("id")]
|
||||
assert ids == sorted(ids) and ids[0] == 1 # monotonic, starting at 1
|
||||
assert any(f["event"] == "done" for f in frames)
|
||||
|
||||
# Reattach with a mid-stream Last-Event-ID: only strictly-later frames are replayed.
|
||||
cutoff = ids[len(ids) // 2]
|
||||
replayed = [f async for f in rs.stream(run_id=rid, tenant_id=t, project_id=p, last_event_id=cutoff)]
|
||||
assert replayed, "reattach after completion should replay the retained tail"
|
||||
assert all(int(f["id"]) > cutoff for f in replayed)
|
||||
# The retained tail still includes the terminal done frame.
|
||||
assert any(f["event"] == "done" for f in replayed)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""SSRF egress-guard tests (offline: uses IP literals + pre-resolution checks)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from forge.util.ssrf import EgressBlocked, EgressPolicy, validate_url
|
||||
|
||||
_BLOCK = EgressPolicy(block_private=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"http://127.0.0.1/x",
|
||||
"http://localhost/x", # resolves to loopback
|
||||
"http://169.254.169.254/latest/meta-data/", # cloud metadata
|
||||
"http://10.0.0.5/x",
|
||||
"http://192.168.1.1/x",
|
||||
"http://[::1]/x",
|
||||
"http://0.0.0.0/x",
|
||||
"http://[::ffff:127.0.0.1]/x", # IPv4-mapped loopback
|
||||
],
|
||||
)
|
||||
async def test_blocks_internal_targets(url):
|
||||
with pytest.raises(EgressBlocked):
|
||||
await validate_url(url, _BLOCK)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["ftp://example.com", "file:///etc/passwd", "gopher://x"])
|
||||
async def test_blocks_non_http_schemes(url):
|
||||
with pytest.raises(EgressBlocked):
|
||||
await validate_url(url, _BLOCK)
|
||||
|
||||
|
||||
async def test_allows_public_ip_literal():
|
||||
assert await validate_url("https://8.8.8.8/x", _BLOCK) == "https://8.8.8.8/x"
|
||||
|
||||
|
||||
async def test_deny_list_blocks_before_resolution():
|
||||
pol = EgressPolicy(block_private=True, deny_hosts=("evil.example",))
|
||||
with pytest.raises(EgressBlocked):
|
||||
await validate_url("https://api.evil.example/x", pol) # parent-domain match
|
||||
|
||||
|
||||
async def test_allow_list_blocks_other_hosts():
|
||||
pol = EgressPolicy(block_private=True, allow_hosts=("good.example",))
|
||||
with pytest.raises(EgressBlocked):
|
||||
await validate_url("https://other.example/x", pol)
|
||||
# host inside the allowed parent domain + public literal passes
|
||||
assert await validate_url("https://8.8.8.8/x", EgressPolicy(allow_hosts=("8.8.8.8",)))
|
||||
|
||||
|
||||
async def test_block_private_disabled_allows_loopback():
|
||||
assert await validate_url("http://127.0.0.1/x", EgressPolicy(block_private=False))
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Characterization tests for the stats rollups (dashboard + project_stats).
|
||||
|
||||
These pin the EXACT output of the two endpoints for a controlled dataset so the
|
||||
in-memory -> SQL-aggregate refactor can be proven behaviour-preserving. Every expected
|
||||
number here is hand-computed from the seeded traces below.
|
||||
|
||||
Dataset (tenant t_stats), 6 traces across 2 projects:
|
||||
A P1 W1 run done tok 10 cost 0.10 lat 100 -1h (in window)
|
||||
B P1 W1 run error tok 20 cost 0.20 lat 300 -2h (in window)
|
||||
C P1 -- assistant done tok 5 cost 0.05 lat 50 -3h (in window)
|
||||
D P2 -- adhoc done tok 8 cost 0.08 lat 80 -4h (in window)
|
||||
E P2 wf_gone run done tok 2 cost 0.02 lat 20 -5h (in window)
|
||||
F P1 W1 run done tok100 cost 1.00 lat1000 -10d (OUT of window)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import Project, Span, Tool, Trace, Workflow
|
||||
from forge.routers.stats import dashboard, project_analytics, project_stats
|
||||
|
||||
TENANT = "t_stats"
|
||||
|
||||
|
||||
async def _seed() -> tuple[str, str]:
|
||||
now = datetime.utcnow()
|
||||
|
||||
def h(n): # n hours ago
|
||||
return now - timedelta(hours=n)
|
||||
|
||||
async with SessionLocal() as s:
|
||||
p1 = Project(tenant_id=TENANT, name="Alpha", slug="alpha")
|
||||
p2 = Project(tenant_id=TENANT, name="Beta", slug="beta")
|
||||
s.add_all([p1, p2])
|
||||
await s.flush()
|
||||
w1 = Workflow(tenant_id=TENANT, project_id=p1.id, name="Support")
|
||||
s.add(w1)
|
||||
s.add(Tool(tenant_id=TENANT, project_id=p1.id, name="t", kind="builtin", config={}))
|
||||
await s.flush()
|
||||
rows = [
|
||||
Trace(tenant_id=TENANT, project_id=p1.id, workflow_id=w1.id, run_id="rA", name="run", status="done", total_tokens=10, total_cost_usd=0.10, latency_ms=100, started_at=h(1)),
|
||||
Trace(tenant_id=TENANT, project_id=p1.id, workflow_id=w1.id, run_id="rB", name="run", status="error", total_tokens=20, total_cost_usd=0.20, latency_ms=300, started_at=h(2)),
|
||||
Trace(tenant_id=TENANT, project_id=p1.id, workflow_id=None, run_id="rC", name="assistant", status="done", total_tokens=5, total_cost_usd=0.05, latency_ms=50, started_at=h(3)),
|
||||
Trace(tenant_id=TENANT, project_id=p2.id, workflow_id=None, run_id="rD", name="adhoc", status="done", total_tokens=8, total_cost_usd=0.08, latency_ms=80, started_at=h(4)),
|
||||
Trace(tenant_id=TENANT, project_id=p2.id, workflow_id="wf_gone", run_id="rE", name="run", status="done", total_tokens=2, total_cost_usd=0.02, latency_ms=20, started_at=h(5)),
|
||||
Trace(tenant_id=TENANT, project_id=p1.id, workflow_id=w1.id, run_id="rF", name="run", status="done", total_tokens=100, total_cost_usd=1.00, latency_ms=1000, started_at=now - timedelta(days=10)),
|
||||
]
|
||||
s.add_all(rows)
|
||||
await s.commit()
|
||||
return p1.id, p2.id
|
||||
|
||||
|
||||
async def test_dashboard_rollups():
|
||||
p1, p2 = await _seed()
|
||||
async with SessionLocal() as s:
|
||||
d = await dashboard(session=s, tenant_id=TENANT)
|
||||
|
||||
assert d["total_runs"] == 6
|
||||
assert d["runs_7d"] == 5
|
||||
assert d["success_rate"] == 80.0 # 4 done of 5 in-window
|
||||
assert d["avg_latency_ms"] == 110 # int((100+300+50+80+20)/5)
|
||||
assert d["spend_7d"] == 0.45
|
||||
|
||||
# per-project counts for the dashboard cards
|
||||
assert d["projects"][p1] == {"workflows": 1, "tools": 1, "runs_7d": 3}
|
||||
assert d["projects"][p2] == {"workflows": 0, "tools": 0, "runs_7d": 2}
|
||||
|
||||
# recent = 8 most recent all-time, newest first (A,B,C,D,E,F)
|
||||
recent = [(r["workflow"], r["project"], r["status"], r["tokens"]) for r in d["recent"]]
|
||||
assert recent == [
|
||||
("Support", "Alpha", "done", 10),
|
||||
("Support", "Alpha", "error", 20),
|
||||
("run", "Alpha", "done", 5),
|
||||
("run", "Beta", "done", 8),
|
||||
("run", "Beta", "done", 2),
|
||||
("Support", "Alpha", "done", 100),
|
||||
]
|
||||
|
||||
# totals (all-time)
|
||||
assert d["totals"] == {
|
||||
"runs": 6, "tokens": 145, "cost_usd": 1.45, "avg_latency_ms": 258,
|
||||
"errors": 1, "error_rate": 16.7,
|
||||
}
|
||||
|
||||
# reports (per project, all-time), cost desc
|
||||
reps = {r["project_id"]: r for r in d["reports"]}
|
||||
assert [r["project_id"] for r in d["reports"]] == [p1, p2]
|
||||
assert reps[p1]["project"] == "Alpha" and reps[p1]["runs"] == 4 and reps[p1]["tokens"] == 135
|
||||
assert reps[p1]["cost_usd"] == 1.35 and reps[p1]["avg_latency_ms"] == 362
|
||||
assert reps[p1]["errors"] == 1 and reps[p1]["error_rate"] == 25.0
|
||||
assert reps[p1]["assistant_cost_usd"] == 0.05 and reps[p1]["assistant_turns"] == 1
|
||||
assert reps[p2]["runs"] == 2 and reps[p2]["cost_usd"] == 0.1 and reps[p2]["assistant_turns"] == 0
|
||||
|
||||
|
||||
async def test_project_stats_rollups():
|
||||
p1, p2 = await _seed()
|
||||
async with SessionLocal() as s:
|
||||
d1 = await project_stats(project_id=p1, session=s, tenant_id=TENANT)
|
||||
d2 = await project_stats(project_id=p2, session=s, tenant_id=TENANT)
|
||||
|
||||
assert d1["totals"] == {"runs": 4, "tokens": 135, "cost_usd": 1.35, "avg_latency_ms": 362, "errors": 1, "error_rate": 25.0}
|
||||
assert d1["last_7d"] == {"runs": 3, "tokens": 35, "cost_usd": 0.35, "avg_latency_ms": 150, "errors": 1, "error_rate": 33.3}
|
||||
assert d1["assistant"] == {"runs": 1, "tokens": 5, "cost_usd": 0.05, "avg_latency_ms": 50, "errors": 0, "error_rate": 0.0, "turns": 1}
|
||||
|
||||
# report rows for P1: workflow "Support" (A,B,F) then assistant (C), cost desc
|
||||
r1 = d1["reports"]
|
||||
assert [(r["kind"], r["label"], r["runs"], r["cost_usd"]) for r in r1] == [
|
||||
("workflow", "Support", 3, 1.3),
|
||||
("assistant", "Forge Assistant", 1, 0.05),
|
||||
]
|
||||
|
||||
# P2 exercises the 'other' (name) group and the deleted-workflow label
|
||||
r2 = d2["reports"]
|
||||
assert [(r["kind"], r["label"], r["runs"], r["cost_usd"]) for r in r2] == [
|
||||
("other", "adhoc", 1, 0.08),
|
||||
("workflow", "(deleted workflow)", 1, 0.02),
|
||||
]
|
||||
|
||||
|
||||
# --- analytics endpoint (time-series + breakdowns) ----------------------------------------
|
||||
# Isolated in its own tenant so it can't collide with the rollup fixtures above regardless of
|
||||
# test order (the suite shares one SQLite file; init_db only create_all's, it never truncates).
|
||||
ATENANT = "t_analytics"
|
||||
|
||||
|
||||
async def _seed_analytics() -> str:
|
||||
now = datetime.utcnow()
|
||||
|
||||
def h(n):
|
||||
return now - timedelta(hours=n)
|
||||
|
||||
async with SessionLocal() as s:
|
||||
p = Project(tenant_id=ATENANT, name="Alpha", slug="alpha")
|
||||
s.add(p)
|
||||
await s.flush()
|
||||
w = Workflow(tenant_id=ATENANT, project_id=p.id, name="Support")
|
||||
s.add(w)
|
||||
await s.flush()
|
||||
t1 = Trace(tenant_id=ATENANT, project_id=p.id, workflow_id=w.id, run_id="rA1", name="run", status="done", source="playground", total_tokens=10, total_cost_usd=0.10, latency_ms=100, started_at=h(1))
|
||||
t2 = Trace(tenant_id=ATENANT, project_id=p.id, workflow_id=w.id, run_id="rA2", name="run", status="error", source="playground", total_tokens=20, total_cost_usd=0.20, latency_ms=3000, started_at=h(2))
|
||||
t3 = Trace(tenant_id=ATENANT, project_id=p.id, workflow_id=None, run_id="rA3", name="run", status="done", source="api", total_tokens=5, total_cost_usd=0.05, latency_ms=50, started_at=h(25))
|
||||
t4 = Trace(tenant_id=ATENANT, project_id=p.id, workflow_id=None, run_id="rA4", name="assistant", status="done", source="assistant", total_tokens=8, total_cost_usd=0.08, latency_ms=5500, started_at=h(3))
|
||||
# Out of the 30-day window but inside the previous (30-60d) window -> feeds prev_totals.
|
||||
t5 = Trace(tenant_id=ATENANT, project_id=p.id, workflow_id=w.id, run_id="rA5", name="run", status="done", source="playground", total_tokens=100, total_cost_usd=1.00, latency_ms=200, started_at=now - timedelta(days=40))
|
||||
s.add_all([t1, t2, t3, t4, t5])
|
||||
await s.flush()
|
||||
s.add_all([
|
||||
Span(tenant_id=ATENANT, trace_id=t1.id, name="get_order", kind="tool", latency_ms=300, input_tokens=0, output_tokens=0, cost_usd=0.0),
|
||||
Span(tenant_id=ATENANT, trace_id=t1.id, name="model", kind="llm", model="claude", latency_ms=800, input_tokens=4, output_tokens=6, cost_usd=0.09),
|
||||
Span(tenant_id=ATENANT, trace_id=t2.id, name="get_order", kind="tool", latency_ms=200, input_tokens=0, output_tokens=0, cost_usd=0.0, error="boom"),
|
||||
])
|
||||
await s.commit()
|
||||
return p.id
|
||||
|
||||
|
||||
async def test_project_analytics():
|
||||
pid = await _seed_analytics()
|
||||
async with SessionLocal() as s:
|
||||
a = await project_analytics(project_id=pid, days=30, session=s, tenant_id=ATENANT)
|
||||
|
||||
assert a["range"]["days"] == 30 and a["range"]["bucket"] == "day"
|
||||
|
||||
# Windowed totals (T1..T4); T5 is out of window.
|
||||
assert a["totals"]["runs"] == 4
|
||||
assert a["totals"]["tokens"] == 43
|
||||
assert a["totals"]["cost_usd"] == 0.43
|
||||
assert a["totals"]["errors"] == 1
|
||||
assert a["totals"]["avg_latency_ms"] == 2162 # int((100+3000+50+5500)/4)
|
||||
|
||||
# Previous 30-60d window holds only T5.
|
||||
assert a["prev_totals"]["runs"] == 1 and a["prev_totals"]["tokens"] == 100
|
||||
|
||||
# 30 days back -> 31 daily points (inclusive), continuous, summing to the 4 in-window runs.
|
||||
assert len(a["timeseries"]) == 31
|
||||
assert sum(p["runs"] for p in a["timeseries"]) == 4
|
||||
assert sum(p["errors"] for p in a["timeseries"]) == 1
|
||||
|
||||
by_src = {r["source"]: r for r in a["by_source"]}
|
||||
assert by_src["playground"]["runs"] == 2
|
||||
assert by_src["api"]["runs"] == 1
|
||||
assert by_src["assistant"]["runs"] == 1
|
||||
|
||||
by_wf = {(r["kind"], r["label"]): r for r in a["by_workflow"]}
|
||||
assert by_wf[("workflow", "Support")]["runs"] == 2
|
||||
assert by_wf[("assistant", "Forge Assistant")]["runs"] == 1
|
||||
assert by_wf[("other", "run")]["runs"] == 1
|
||||
|
||||
tools = {t["name"]: t for t in a["tools"]}
|
||||
assert tools["get_order"]["calls"] == 2
|
||||
assert tools["get_order"]["errors"] == 1
|
||||
assert tools["get_order"]["avg_latency_ms"] == 250 # (300+200)/2
|
||||
|
||||
models = {m["model"]: m for m in a["models"]}
|
||||
assert models["claude"]["calls"] == 1
|
||||
assert models["claude"]["tokens"] == 10
|
||||
assert models["claude"]["cost_usd"] == 0.09
|
||||
|
||||
hist = {b["label"]: b["count"] for b in a["latency_histogram"]}
|
||||
assert hist["<250ms"] == 2 # T1 (100), T3 (50)
|
||||
assert hist["2-5s"] == 1 # T2 (3000)
|
||||
assert hist["5-10s"] == 1 # T4 (5500)
|
||||
|
||||
# Recent activity feed: the 4 in-window runs, newest first.
|
||||
assert len(a["recent"]) == 4
|
||||
assert a["recent"][0]["status"] == "done" # T1, most recent
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Which nodes' streamed tokens reach the chat bubble.
|
||||
|
||||
Classifier and structured-`llm` nodes still stream tokens over the messages channel even
|
||||
though their result (a routing label / structured_response) never enters `messages`. The run
|
||||
stream suppresses those by node id so a client no longer has to guess from node-name patterns
|
||||
(the old `classif|router|^start$` regex). Answer producers stream normally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.services.runs import _internal_message_nodes
|
||||
|
||||
|
||||
def test_suppresses_classifier_router_start_and_structured_llm():
|
||||
nodes = [
|
||||
{"id": "start", "type": "start"},
|
||||
{"id": "intent", "type": "classifier"},
|
||||
{"id": "route", "type": "router"},
|
||||
{"id": "answer", "type": "agent"},
|
||||
{"id": "reply", "type": "llm"}, # unstructured llm = an answer producer
|
||||
{"id": "extract", "type": "llm", "config": {"response_format": {"mode": "structured", "schema": {}}}},
|
||||
{"id": "finish", "type": "end"},
|
||||
]
|
||||
suppressed = _internal_message_nodes(nodes)
|
||||
assert suppressed == {"start", "intent", "route", "extract", "finish"}
|
||||
# Answer-producing nodes stream normally.
|
||||
assert "answer" not in suppressed
|
||||
assert "reply" not in suppressed
|
||||
|
||||
|
||||
def test_unstructured_llm_is_not_suppressed():
|
||||
# response_format present but not "structured" -> still an answer producer.
|
||||
nodes = [{"id": "reply", "type": "llm", "config": {"response_format": {"mode": "text"}}}]
|
||||
assert _internal_message_nodes(nodes) == set()
|
||||
|
||||
|
||||
def test_tolerates_malformed_nodes():
|
||||
nodes = [None, "oops", {"type": "classifier"}, {"id": "c2", "type": "classifier"}]
|
||||
# Non-dicts and a dict without an id are skipped (a null id would match tokens that have no
|
||||
# langgraph_node and wrongly drop answers); only the well-formed classifier is suppressed.
|
||||
assert _internal_message_nodes(nodes) == {"c2"}
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Human-readable tool names in the run stream.
|
||||
|
||||
A tool's underscore identifier (e.g. `get_product_data`) is what the model
|
||||
calls, but end-user chat surfaces (the agent chat UI, embeds) should show a friendly
|
||||
label. The run stream binds a name->label map for the turn, and `jsonable` relabels every
|
||||
serialized tool_call with a `display_name` (falling back to the identifier when unmapped),
|
||||
while leaving the model-facing `name` untouched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
from forge.tools.rest import execute_rest
|
||||
from forge.util.serialize import (
|
||||
jsonable,
|
||||
reset_tool_display_names,
|
||||
set_tool_display_names,
|
||||
)
|
||||
|
||||
|
||||
def _ai_with_calls(*names: str) -> AIMessage:
|
||||
return AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"name": n, "args": {}, "id": f"call_{i}"} for i, n in enumerate(names)],
|
||||
)
|
||||
|
||||
|
||||
def test_display_name_maps_when_bound():
|
||||
token = set_tool_display_names({"get_product_catalog": "Product catalog"})
|
||||
try:
|
||||
out = jsonable(_ai_with_calls("get_product_catalog"))
|
||||
finally:
|
||||
reset_tool_display_names(token)
|
||||
call = out["tool_calls"][0]
|
||||
# Model-facing name is unchanged; display_name carries the human label.
|
||||
assert call["name"] == "get_product_catalog"
|
||||
assert call["display_name"] == "Product catalog"
|
||||
|
||||
|
||||
def test_display_name_falls_back_to_identifier_when_unmapped():
|
||||
# Bound map that doesn't cover this tool -> display_name == the identifier.
|
||||
token = set_tool_display_names({"other_tool": "Other"})
|
||||
try:
|
||||
out = jsonable(_ai_with_calls("get_order_totals"))
|
||||
finally:
|
||||
reset_tool_display_names(token)
|
||||
call = out["tool_calls"][0]
|
||||
assert call["name"] == "get_order_totals"
|
||||
assert call["display_name"] == "get_order_totals"
|
||||
|
||||
|
||||
def test_display_name_falls_back_with_no_map_bound():
|
||||
# No map set for this context (e.g. resume/non-stream paths) -> identifier passthrough.
|
||||
out = jsonable(_ai_with_calls("get_project_overview"))
|
||||
call = out["tool_calls"][0]
|
||||
assert call["display_name"] == "get_project_overview"
|
||||
|
||||
|
||||
def test_no_tool_calls_stays_none():
|
||||
out = jsonable(AIMessage(content="hello"))
|
||||
assert out["tool_calls"] is None
|
||||
|
||||
|
||||
# --- the LIVE "calling" + terminal "done"/"error" custom frames (REST tool) ---------------
|
||||
# The tool emits a "calling" frame before the request (label the spinner) and a paired terminal
|
||||
# frame when it ENDS (clear the spinner): "done" on success, "error" on failure. Both carry the
|
||||
# same tool id + display_name (config.display_name, else the identifier) so a client can pair them.
|
||||
|
||||
def _rest_cfg(**extra) -> dict:
|
||||
return {
|
||||
"name": "get_order_totals",
|
||||
"kind": "rest_api",
|
||||
"request": {"method": "GET", "url_template": "https://api.acme.dev/totals", "fields": []},
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def _client(handler) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def _ok_client() -> httpx.AsyncClient:
|
||||
return _client(lambda r: httpx.Response(200, json={"ok": True}))
|
||||
|
||||
|
||||
async def _run(cfg: dict, client: httpx.AsyncClient) -> list[dict]:
|
||||
frames: list[dict] = []
|
||||
try:
|
||||
await execute_rest(
|
||||
cfg, {}, tenant_id="t", project_id="p", context={}, client=client, stream_writer=frames.append,
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
return frames
|
||||
|
||||
|
||||
async def test_calling_frame_includes_display_name():
|
||||
frames = await _run(_rest_cfg(display_name="Order totals"), _ok_client())
|
||||
calling = [f for f in frames if f.get("status") == "calling"]
|
||||
assert calling, "expected a 'calling' custom frame"
|
||||
# model-facing id under `tool`; human label under `display_name`
|
||||
assert calling[0]["tool"] == "get_order_totals"
|
||||
assert calling[0]["display_name"] == "Order totals"
|
||||
|
||||
|
||||
async def test_calling_frame_display_name_falls_back_to_identifier():
|
||||
frames = await _run(_rest_cfg(), _ok_client())
|
||||
calling = [f for f in frames if f.get("status") == "calling"]
|
||||
assert calling and calling[0]["display_name"] == "get_order_totals"
|
||||
|
||||
|
||||
async def test_terminal_done_frame_on_success():
|
||||
frames = await _run(_rest_cfg(display_name="Order totals"), _ok_client())
|
||||
statuses = [f.get("status") for f in frames]
|
||||
# calling first, then a terminal done - so a client can clear the spinner.
|
||||
assert statuses == ["calling", "done"]
|
||||
done = frames[-1]
|
||||
assert done["tool"] == "get_order_totals"
|
||||
assert done["display_name"] == "Order totals"
|
||||
assert done["status_code"] == 200
|
||||
assert "latency_ms" in done
|
||||
|
||||
|
||||
async def test_terminal_error_frame_on_failure():
|
||||
# A 500 raises out of execute_rest, but the spinner must still be cleared via an "error" frame.
|
||||
client = _client(lambda r: httpx.Response(500, json={"detail": "boom"}))
|
||||
frames: list[dict] = []
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await execute_rest(
|
||||
_rest_cfg(display_name="Order totals"), {},
|
||||
tenant_id="t", project_id="p", context={}, client=client, stream_writer=frames.append,
|
||||
)
|
||||
await client.aclose()
|
||||
statuses = [f.get("status") for f in frames]
|
||||
assert statuses == ["calling", "error"]
|
||||
assert frames[-1]["display_name"] == "Order totals"
|
||||
assert frames[-1]["status_code"] == 500
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Subworkflow node: a parent workflow runs a referenced child as a reusable component."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from forge.engine.compiler import compile_workflow
|
||||
from forge.services.runtime import make_runtime_ctx
|
||||
|
||||
_CHILD = {
|
||||
"id": "child", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "agent",
|
||||
"nodes": [
|
||||
{"id": "agent", "type": "agent", "config": {"flavor": "agent", "model": "fake:Child answered."}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "agent", "target": "end"}],
|
||||
}
|
||||
|
||||
_PARENT = {
|
||||
"id": "parent", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "start",
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "config": {}},
|
||||
{"id": "sub", "type": "subworkflow", "config": {"workflow_id": "child_1"}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "start", "target": "sub"}, {"source": "sub", "target": "end"}],
|
||||
}
|
||||
|
||||
|
||||
async def test_subworkflow_runs_child():
|
||||
ctx = make_runtime_ctx("t_sub", "p_sub")
|
||||
ctx.checkpointer = InMemorySaver()
|
||||
ctx.workflows = {"child_1": _CHILD}
|
||||
graph = compile_workflow(_PARENT, ctx)
|
||||
out = await graph.ainvoke({"messages": [HumanMessage(content="hi")]}, {"configurable": {"thread_id": "s1"}})
|
||||
assert out["messages"][-1].content == "Child answered."
|
||||
|
||||
|
||||
async def test_missing_subworkflow_is_passthrough():
|
||||
ctx = make_runtime_ctx("t_sub2", "p_sub2")
|
||||
ctx.checkpointer = InMemorySaver()
|
||||
ctx.workflows = {} # referenced child not present
|
||||
graph = compile_workflow(_PARENT, ctx)
|
||||
out = await graph.ainvoke({"messages": [HumanMessage(content="hi")]}, {"configurable": {"thread_id": "s2"}})
|
||||
# no child -> passthrough; the original message survives, no crash
|
||||
assert out["messages"][-1].content == "hi"
|
||||
@@ -0,0 +1,90 @@
|
||||
"""`tls_skip_verify` is gated: TLS verification may be disabled only for a host explicitly on
|
||||
FORGE_EGRESS_ALLOW_PRIVATE_HOSTS. For any other host the flag is ignored and verification stays on,
|
||||
so certificate checks can never be turned off for an arbitrary/public endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from forge.util.http import (
|
||||
aclose_shared_client,
|
||||
insecure_async_client,
|
||||
select_client,
|
||||
shared_async_client,
|
||||
)
|
||||
from forge.util.ssrf import EgressPolicy
|
||||
|
||||
|
||||
async def test_tls_skip_verify_is_gated_to_allow_private_hosts():
|
||||
policy = EgressPolicy(block_private=True, allow_private_hosts=("host.docker.internal",))
|
||||
try:
|
||||
# opted-in internal host + skip_verify -> verification-disabled client
|
||||
assert select_client(
|
||||
"https://host.docker.internal:9002/x", skip_verify=True, policy=policy
|
||||
) is insecure_async_client()
|
||||
|
||||
# public host + skip_verify -> IGNORED, stays on the verified shared client
|
||||
assert select_client(
|
||||
"https://api.example.com/x", skip_verify=True, policy=policy
|
||||
) is shared_async_client()
|
||||
|
||||
# allow-private host but skip_verify off -> verified shared client
|
||||
assert select_client(
|
||||
"https://host.docker.internal/x", skip_verify=False, policy=policy
|
||||
) is shared_async_client()
|
||||
|
||||
# an explicit client (e.g. a test/mock) always wins over the gate
|
||||
mock = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200)))
|
||||
try:
|
||||
assert select_client(
|
||||
"https://host.docker.internal/x", skip_verify=True, policy=policy, override=mock
|
||||
) is mock
|
||||
finally:
|
||||
await mock.aclose()
|
||||
|
||||
# suffix match honored: a subdomain of an allow-private parent is still gated-in
|
||||
policy2 = EgressPolicy(block_private=True, allow_private_hosts=("internal.corp",))
|
||||
assert select_client(
|
||||
"https://svc.internal.corp/x", skip_verify=True, policy=policy2
|
||||
) is insecure_async_client()
|
||||
finally:
|
||||
await aclose_shared_client()
|
||||
|
||||
|
||||
async def test_guarded_request_reselects_client_per_redirect_hop(monkeypatch):
|
||||
"""A redirect off an allow-private host to a public host must re-select the client from the
|
||||
hop's OWN host, so verify-off (tls_skip_verify) never carries onto a non-allow-private hop.
|
||||
Regression for the MEDIUM finding that guarded_request reused one client across all hops."""
|
||||
import forge.util.ssrf as ssrf
|
||||
|
||||
seen_hosts: list[str] = []
|
||||
|
||||
async def _no_validate(url, policy=None): # skip real DNS/SSRF resolution in the unit test
|
||||
return url
|
||||
|
||||
def _handler(req: httpx.Request) -> httpx.Response:
|
||||
if req.url.host == "internal.corp":
|
||||
return httpx.Response(302, headers={"location": "https://public.example/final"})
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
mock = httpx.AsyncClient(transport=httpx.MockTransport(_handler))
|
||||
|
||||
def _spy_select(url, *, skip_verify, policy, override=None):
|
||||
seen_hosts.append(httpx.URL(url).host)
|
||||
return mock
|
||||
|
||||
monkeypatch.setattr(ssrf, "validate_url", _no_validate)
|
||||
monkeypatch.setattr("forge.util.http.select_client", _spy_select)
|
||||
|
||||
policy = EgressPolicy(block_private=True, allow_private_hosts=("internal.corp",))
|
||||
try:
|
||||
r = await ssrf.guarded_request(
|
||||
None, "GET", "https://internal.corp/x", policy=policy, skip_verify=True, follow_redirects=True,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
finally:
|
||||
await mock.aclose()
|
||||
|
||||
# select_client is consulted per hop with that hop's own host, so the real select_client would
|
||||
# hand the public leg the VERIFIED client (proven by the gate test above), not the insecure one.
|
||||
assert seen_hosts == ["internal.corp", "public.example"]
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Tool-I/O capture for traces.
|
||||
|
||||
A REST tool records its FRAMED request (resolved URL, query, headers/cookies templated
|
||||
from {{ctx.*}}) plus the response into a context var; the ForgeTracer reads it back onto
|
||||
the tool span. This is what makes a run-time "works in test, 401s in a run" visible: the
|
||||
capture runs on failure too, so a dropped ctx cookie / a 401 shows up in the trace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from forge.config import settings
|
||||
from forge.tools.rest import execute_rest
|
||||
from forge.tracing import tool_io
|
||||
from forge.tracing.tracer import ForgeTracer
|
||||
|
||||
|
||||
def _cfg(**extra) -> dict:
|
||||
return {
|
||||
"name": f"tool_{uuid.uuid4().hex[:6]}",
|
||||
"kind": "rest_api",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url_template": "https://api.acme.dev/items/{id}",
|
||||
"fields": [
|
||||
{"path": "id", "in": "path", "llm_visible": True},
|
||||
{"path": "q", "in": "query", "llm_visible": True},
|
||||
# server-injected session cookie, templated from run context (not an LLM arg)
|
||||
{"path": "sid", "in": "cookie", "default": "{{ctx.jsessionid}}", "llm_visible": False},
|
||||
],
|
||||
"headers": [{"name": "X-CSRF-Token", "value": "{{ctx.csrf}}"}],
|
||||
},
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def _client(handler) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
async def test_capture_frames_request_and_response():
|
||||
tool_io.clear_tool_io()
|
||||
client = _client(lambda r: httpx.Response(200, json={"ok": True}))
|
||||
await execute_rest(
|
||||
_cfg(), {"id": "123", "q": "widget"},
|
||||
tenant_id="t", project_id="p", context={"jsessionid": "SESS", "csrf": "CSRF-XYZ"}, client=client,
|
||||
)
|
||||
await client.aclose()
|
||||
|
||||
rec = tool_io.take_tool_io()
|
||||
assert rec is not None
|
||||
inp, out = rec["input"], rec["output"]
|
||||
# the agent's own args, and the fully framed request
|
||||
assert inp["args"] == {"id": "123", "q": "widget"}
|
||||
assert inp["method"] == "GET"
|
||||
assert inp["url"] == "https://api.acme.dev/items/123" # path param substituted
|
||||
assert inp["query"] == {"q": "widget"}
|
||||
assert inp["headers"]["X-CSRF-Token"] == "CSRF-XYZ" # {{ctx.csrf}} resolved (full, redact off)
|
||||
assert inp["cookies"]["sid"] == "SESS" # {{ctx.jsessionid}} resolved
|
||||
assert out["status"] == 200 and out["response"] == {"ok": True} and out["error"] is None
|
||||
|
||||
|
||||
async def test_capture_records_a_failed_call():
|
||||
"""A 401 raises out of execute_rest, but the framed request + status must still be captured
|
||||
so the failure is visible in the trace (not silently swallowed)."""
|
||||
tool_io.clear_tool_io()
|
||||
client = _client(lambda r: httpx.Response(401, json={"detail": "no session"}))
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await execute_rest(
|
||||
_cfg(), {"id": "9"},
|
||||
tenant_id="t", project_id="p", context={}, client=client, # no ctx -> cookie/csrf dropped
|
||||
)
|
||||
await client.aclose()
|
||||
|
||||
rec = tool_io.take_tool_io()
|
||||
assert rec is not None
|
||||
assert rec["output"]["status"] == 401 and rec["output"]["error"]
|
||||
# the dropped session is visible by its ABSENCE: no sid cookie / empty csrf were sent
|
||||
assert "sid" not in rec["input"]["cookies"]
|
||||
|
||||
|
||||
async def test_redaction_masks_secrets_when_enabled(monkeypatch):
|
||||
tool_io.clear_tool_io()
|
||||
monkeypatch.setattr(settings, "trace_tool_io_redact", True)
|
||||
client = _client(lambda r: httpx.Response(200, json={"ok": True}))
|
||||
await execute_rest(
|
||||
_cfg(), {"id": "1"},
|
||||
tenant_id="t", project_id="p", context={"jsessionid": "SESS", "csrf": "CSRF-XYZ"}, client=client,
|
||||
)
|
||||
await client.aclose()
|
||||
|
||||
rec = tool_io.take_tool_io()
|
||||
assert rec["input"]["headers"]["X-CSRF-Token"].startswith("•••") # masked
|
||||
assert rec["input"]["cookies"]["sid"].startswith("•••")
|
||||
assert "CSRF-XYZ" not in str(rec["input"]) # secret not persisted
|
||||
|
||||
|
||||
async def test_disabled_capture_is_a_noop(monkeypatch):
|
||||
tool_io.clear_tool_io()
|
||||
monkeypatch.setattr(settings, "trace_tool_io", False)
|
||||
client = _client(lambda r: httpx.Response(200, json={"ok": True}))
|
||||
await execute_rest(_cfg(), {"id": "1"}, tenant_id="t", project_id="p", context={}, client=client)
|
||||
await client.aclose()
|
||||
assert tool_io.take_tool_io() is None
|
||||
|
||||
|
||||
def test_tracer_attaches_matching_record_to_tool_span():
|
||||
"""on_tool_end merges a record whose name matches the span's tool."""
|
||||
tool_io.clear_tool_io()
|
||||
tr = ForgeTracer()
|
||||
rid = uuid.uuid4()
|
||||
tr.on_tool_start({"name": "fetch_item"}, '{"id": "1"}', run_id=rid)
|
||||
tool_io.set_tool_io("fetch_item", request={"method": "GET", "url": "u"}, response={"status": 200})
|
||||
tr.on_tool_end({"ok": True}, run_id=rid)
|
||||
|
||||
sp = tr.spans[str(rid)]
|
||||
assert sp.input == {"method": "GET", "url": "u"} and sp.output == {"status": 200}
|
||||
|
||||
|
||||
def test_span_dto_exposes_io():
|
||||
"""The traces API must serialize the captured input/output to the web client."""
|
||||
from forge.models import Span
|
||||
from forge.schemas.dto import SpanOut
|
||||
|
||||
sp = Span(
|
||||
id="s1", tenant_id="t", trace_id="tr", name="tool · fetch_item", kind="tool", latency_ms=5,
|
||||
input={"method": "GET", "url": "https://api.acme.dev/items/1"}, output={"status": 200},
|
||||
input_tokens=0, output_tokens=0, cost_usd=0.0,
|
||||
)
|
||||
dto = SpanOut.model_validate(sp)
|
||||
assert dto.input == {"method": "GET", "url": "https://api.acme.dev/items/1"}
|
||||
assert dto.output == {"status": 200}
|
||||
|
||||
|
||||
def test_tracer_ignores_stale_record_from_another_tool():
|
||||
"""A record left by an EARLIER tool must not attach to a different tool's span; that span
|
||||
falls back to its own raw return value instead."""
|
||||
tool_io.clear_tool_io()
|
||||
tr = ForgeTracer()
|
||||
rid = uuid.uuid4()
|
||||
tr.on_tool_start({"name": "lookup"}, "{}", run_id=rid)
|
||||
tool_io.set_tool_io("some_other_tool", request={"method": "GET"}, response={"status": 200})
|
||||
tr.on_tool_end("plain string result", run_id=rid)
|
||||
|
||||
sp = tr.spans[str(rid)]
|
||||
assert sp.output == "plain string result" # fell back to the raw return
|
||||
assert sp.input == "{}" # kept the provisional LLM args
|
||||
@@ -0,0 +1,83 @@
|
||||
"""REST tool reliability config: cache, rate_limit, retry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from forge.tools.rest import execute_rest
|
||||
|
||||
|
||||
def _cfg(**extra) -> dict:
|
||||
return {
|
||||
"name": f"t_{uuid.uuid4().hex[:8]}",
|
||||
"kind": "rest_api",
|
||||
"request": {"method": "GET", "url_template": "https://api.acme.dev/v2/ping", "fields": []},
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
async def test_cache_serves_repeat_get_without_second_call():
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(req):
|
||||
calls["n"] += 1
|
||||
return httpx.Response(200, json={"v": calls["n"]})
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
cfg = _cfg(cache={"ttl_seconds": 60})
|
||||
a = await execute_rest(cfg, {}, tenant_id="t", project_id="p", client=client)
|
||||
b = await execute_rest(cfg, {}, tenant_id="t", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
assert calls["n"] == 1 and a["raw"] == b["raw"] # second served from cache
|
||||
|
||||
|
||||
async def test_rate_limit_blocks_second_call():
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, json={})))
|
||||
cfg = _cfg(rate_limit={"per_minute": 1})
|
||||
await execute_rest(cfg, {}, tenant_id="t_rl", project_id="p", client=client)
|
||||
with pytest.raises(RuntimeError):
|
||||
await execute_rest(cfg, {}, tenant_id="t_rl", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_retry_recovers_from_transient_500():
|
||||
state = {"n": 0}
|
||||
|
||||
def handler(req):
|
||||
state["n"] += 1
|
||||
return httpx.Response(500 if state["n"] == 1 else 200, json={"ok": state["n"]})
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
cfg = _cfg(retry={"max_retries": 2, "initial_delay": 0.001, "jitter": False, "retry_on": ["http_error"]})
|
||||
res = await execute_rest(cfg, {}, tenant_id="t", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
assert state["n"] == 2 and res["status"] == 200
|
||||
|
||||
|
||||
async def test_no_retry_when_not_configured():
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(500, json={})))
|
||||
cfg = _cfg() # no retry policy
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await execute_rest(cfg, {}, tenant_id="t", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_missing_path_param_raises_clear_error():
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, json={})))
|
||||
cfg = _cfg()
|
||||
cfg["request"]["url_template"] = "https://api.acme.dev/v2/orders/{order_id}"
|
||||
with pytest.raises(ValueError, match="order_id"):
|
||||
await execute_rest(cfg, {}, tenant_id="t", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
|
||||
|
||||
def test_cap_payload_truncates_large_only():
|
||||
from forge.tools.projection import cap_payload
|
||||
|
||||
assert cap_payload({"a": 1}, 20000) == {"a": 1} # small passes through unchanged
|
||||
big = "x" * 50000
|
||||
out = cap_payload(big, 100)
|
||||
assert isinstance(out, str) and len(out) < 50000 and "truncated" in out
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Regression: ToolRuntime must be injected into materialized REST/GraphQL tools.
|
||||
|
||||
Two historical failure modes (both caused by `from __future__ import annotations`
|
||||
in forge/tools/rest.py):
|
||||
1. compile time - NameError("ToolRuntime") when create_agent resolved the string
|
||||
annotation against module globals where ToolRuntime wasn't imported.
|
||||
2. call time - "_call() missing 1 required positional argument: 'runtime'":
|
||||
langchain_core's StructuredTool detects injectable params via
|
||||
inspect.signature(fn) (raw, unevaluated annotations), so a string annotation
|
||||
made the runtime arg invisible and it was stripped during validation.
|
||||
|
||||
These tests exercise the real create_agent → ToolNode → StructuredTool path with a
|
||||
scripted model that actually calls the tool.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.tools import rest
|
||||
|
||||
REST_CFG = {
|
||||
"name": "get_thing",
|
||||
"description": "Fetch a thing.",
|
||||
"kind": "rest_api",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url_template": "https://api.example.dev/things/{thing_id}",
|
||||
"fields": [
|
||||
{"path": "thing_id", "type": "string", "in": "path", "required": True, "llm_visible": True},
|
||||
],
|
||||
},
|
||||
"response": {},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def captured_exec(monkeypatch):
|
||||
"""Stub execute_rest and capture what the tool coroutine passes through."""
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_exec(cfg, kwargs, *, tenant_id, project_id, context=None, auth_resolver=None, stream_writer=None, client=None, egress_policy=None):
|
||||
captured["kwargs"] = kwargs
|
||||
captured["context"] = context
|
||||
captured["tenant_id"] = tenant_id
|
||||
return {"raw": {"ok": True}, "projected": {"ok": True}, "status": 200, "latency_ms": 1}
|
||||
|
||||
monkeypatch.setattr(rest, "execute_rest", fake_exec)
|
||||
return captured
|
||||
|
||||
|
||||
def test_runtime_param_is_visible_to_langchain():
|
||||
"""The injected-arg detection must see `runtime` as a real ToolRuntime class."""
|
||||
tool = rest.build_rest_tool(REST_CFG, CompileContext(tenant_id="t", project_id="p"))
|
||||
assert tool._injected_args_keys == frozenset({"runtime"}), (
|
||||
"StructuredTool can't see the runtime param - string annotations strike again "
|
||||
"(check for `from __future__ import annotations` in forge/tools/rest.py)"
|
||||
)
|
||||
# The model-facing schema must NOT advertise runtime as an input.
|
||||
assert "runtime" not in (tool.tool_call_schema.model_json_schema().get("properties") or {})
|
||||
|
||||
|
||||
async def test_zero_field_tool_executes_without_injection(captured_exec):
|
||||
"""Tools with NO llm-visible fields (empty args schema) hit langchain_core's empty-schema
|
||||
short-circuit, which drops even injected args - the coroutine must tolerate runtime=None."""
|
||||
from langchain.agents import create_agent
|
||||
|
||||
cfg = {
|
||||
"name": "get_weather",
|
||||
"description": "Fetch current weather.",
|
||||
"kind": "rest_api",
|
||||
"request": {"method": "GET", "url_template": "https://api.example.dev/weather", "fields": []},
|
||||
"response": {},
|
||||
}
|
||||
|
||||
class ScriptedModel(GenericFakeChatModel):
|
||||
def bind_tools(self, tools=None, **kwargs): # noqa: ANN001
|
||||
return self
|
||||
|
||||
script = itertools.cycle(
|
||||
[
|
||||
AIMessage(content="", tool_calls=[{"name": "get_weather", "args": {}, "id": "c1", "type": "tool_call"}]),
|
||||
AIMessage(content="done"),
|
||||
]
|
||||
)
|
||||
tool = rest.build_rest_tool(cfg, CompileContext(tenant_id="t", project_id="p"))
|
||||
agent = create_agent(model=ScriptedModel(messages=script), tools=[tool])
|
||||
|
||||
out = await agent.ainvoke({"messages": [{"role": "user", "content": "weather?"}]})
|
||||
|
||||
assert captured_exec["kwargs"] == {}
|
||||
tool_msgs = [m for m in out["messages"] if getattr(m, "type", "") == "tool"]
|
||||
assert tool_msgs and "ok" in str(tool_msgs[-1].content)
|
||||
|
||||
|
||||
async def test_agent_tool_call_injects_runtime(captured_exec):
|
||||
"""Full loop: agent's model emits a tool call; ToolNode must inject runtime."""
|
||||
from langchain.agents import create_agent
|
||||
|
||||
class ScriptedModel(GenericFakeChatModel):
|
||||
def bind_tools(self, tools=None, **kwargs): # noqa: ANN001
|
||||
return self
|
||||
|
||||
script = itertools.cycle(
|
||||
[
|
||||
AIMessage(content="", tool_calls=[{"name": "get_thing", "args": {"thing_id": "42"}, "id": "c1", "type": "tool_call"}]),
|
||||
AIMessage(content="done"),
|
||||
]
|
||||
)
|
||||
tool = rest.build_rest_tool(REST_CFG, CompileContext(tenant_id="t", project_id="p"))
|
||||
agent = create_agent(model=ScriptedModel(messages=script), tools=[tool])
|
||||
|
||||
out = await agent.ainvoke({"messages": [{"role": "user", "content": "get thing 42"}]})
|
||||
|
||||
# The tool actually executed (no TypeError about 'runtime') with the model's args.
|
||||
assert captured_exec["kwargs"] == {"thing_id": "42"}
|
||||
tool_msgs = [m for m in out["messages"] if getattr(m, "type", "") == "tool"]
|
||||
assert tool_msgs, "tool result message missing from agent transcript"
|
||||
assert "ok" in str(tool_msgs[-1].content)
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Tool Sets: service CRUD + membership, agent toolset->tools resolution, and the REST API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.main import create_app
|
||||
from forge.models import Project, Tool, User
|
||||
from forge.services.runtime import build_compile_context
|
||||
from forge.services.tool_sets import ToolSetService
|
||||
from forge.services.tools import ToolService
|
||||
|
||||
|
||||
async def _seed(tenant: str, slug: str) -> tuple[str, str, str]:
|
||||
async with SessionLocal() as s:
|
||||
proj = Project(tenant_id=tenant, name="TS Proj", slug=slug, config={})
|
||||
s.add(proj)
|
||||
await s.flush()
|
||||
t1 = Tool(tenant_id=tenant, project_id=proj.id, name="alpha", kind="builtin",
|
||||
config={"builtin": "calculator", "description": "a"})
|
||||
t2 = Tool(tenant_id=tenant, project_id=proj.id, name="beta", kind="builtin",
|
||||
config={"builtin": "current_time", "description": "b"})
|
||||
s.add_all([t1, t2])
|
||||
await s.commit()
|
||||
for obj in (proj, t1, t2):
|
||||
await s.refresh(obj)
|
||||
return proj.id, t1.id, t2.id
|
||||
|
||||
|
||||
async def test_tool_set_service_crud_and_membership():
|
||||
tenant = "t_ts_svc"
|
||||
pid, t1, t2 = await _seed(tenant, "ts-svc")
|
||||
async with SessionLocal() as s:
|
||||
ts = await ToolSetService.create(s, tenant, pid, name="Billing Tools", description="billing", tool_ids=[t1, t2])
|
||||
assert ts.slug == "billing-tools"
|
||||
assert set(await ToolSetService.member_ids(s, tenant, ts.id)) == {t1, t2}
|
||||
assert set((await ToolSetService.members_map(s, tenant, pid))[ts.id]) == {t1, t2}
|
||||
assert set(await ToolSetService.tool_ids_for_sets(s, tenant, pid, [ts.id])) == {t1, t2}
|
||||
|
||||
# unknown / cross-project ids are filtered out of membership
|
||||
ts2 = await ToolSetService.create(s, tenant, pid, name="X", tool_ids=[t1, "does-not-exist"])
|
||||
assert await ToolSetService.member_ids(s, tenant, ts2.id) == [t1]
|
||||
|
||||
# add / remove membership
|
||||
await ToolSetService.remove_member(s, ts, t1)
|
||||
assert await ToolSetService.member_ids(s, tenant, ts.id) == [t2]
|
||||
await ToolSetService.add_member(s, ts, t1)
|
||||
assert set(await ToolSetService.member_ids(s, tenant, ts.id)) == {t1, t2}
|
||||
await ToolSetService.add_member(s, ts, t1) # idempotent (no duplicate row)
|
||||
assert len(await ToolSetService.member_ids(s, tenant, ts.id)) == 2
|
||||
|
||||
# rename regenerates a unique slug (collides with ts2's "x")
|
||||
ts = await ToolSetService.update(s, ts, name="X")
|
||||
assert ts.slug == "x-2"
|
||||
|
||||
# update can replace membership wholesale
|
||||
ts = await ToolSetService.update(s, ts, tool_ids=[t2])
|
||||
assert await ToolSetService.member_ids(s, tenant, ts.id) == [t2]
|
||||
|
||||
# delete removes the set and its membership rows
|
||||
set_id = ts.id
|
||||
await ToolSetService.delete(s, ts)
|
||||
assert await ToolSetService.get(s, tenant, set_id) is None
|
||||
assert set_id not in await ToolSetService.members_map(s, tenant, pid)
|
||||
|
||||
|
||||
async def test_tool_deletion_removes_membership():
|
||||
tenant = "t_ts_del"
|
||||
pid, t1, t2 = await _seed(tenant, "ts-del")
|
||||
async with SessionLocal() as s:
|
||||
ts = await ToolSetService.create(s, tenant, pid, name="S", tool_ids=[t1, t2])
|
||||
tool = await ToolService.get(s, tenant, t1)
|
||||
await ToolService.delete(s, tool) # deleting a tool must drop its membership rows
|
||||
assert await ToolSetService.member_ids(s, tenant, ts.id) == [t2]
|
||||
|
||||
|
||||
async def test_build_compile_context_resolves_toolset_to_member_tools():
|
||||
tenant = "t_ts_ctx"
|
||||
pid, t1, t2 = await _seed(tenant, "ts-ctx")
|
||||
async with SessionLocal() as s:
|
||||
set_id = (await ToolSetService.create(s, tenant, pid, name="Set A", tool_ids=[t1, t2])).id
|
||||
async with SessionLocal() as s:
|
||||
ctx = await build_compile_context(s, tenant_id=tenant, project_id=pid)
|
||||
# membership is loaded onto the compile context
|
||||
assert set(ctx.toolset_members.get(set_id, [])) == {t1, t2}
|
||||
# an agent granted only the set resolves to the set's member tool ids...
|
||||
assert set(ctx.resolve_tool_ids([], [set_id])) == {t1, t2}
|
||||
# ...and to the materialized tools (both builtins compiled into the registry)
|
||||
assert len(ctx.tools_for(ctx.resolve_tool_ids([], [set_id]))) == 2
|
||||
|
||||
|
||||
async def test_tool_sets_api_end_to_end():
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
# register a real user (mutations require a real principal, not the dev fallback)
|
||||
reg = await c.post("/v1/auth/register", json={"email": f"u{uuid.uuid4().hex[:10]}@example.com", "password": "supersecret1"})
|
||||
assert reg.status_code == 201, reg.text
|
||||
c.headers["Authorization"] = f"Bearer {reg.json()['access_token']}"
|
||||
# project + two tools, all via the API (one consistent tenant = the registered workspace)
|
||||
pid = (await c.post("/v1/projects", json={"name": "API TS", "slug": "api-ts"})).json()["id"]
|
||||
|
||||
def _mk(name: str, builtin: str) -> dict:
|
||||
return {"name": name, "kind": "builtin", "config": {"builtin": builtin, "description": name}}
|
||||
|
||||
t1 = (await c.post(f"/v1/projects/{pid}/tools", json=_mk("aa", "calculator"))).json()["id"]
|
||||
t2 = (await c.post(f"/v1/projects/{pid}/tools", json=_mk("bb", "current_time"))).json()["id"]
|
||||
|
||||
# create a set with one member
|
||||
r = await c.post(f"/v1/projects/{pid}/tool-sets", json={"name": "Group One", "description": "g1", "tool_ids": [t1]})
|
||||
assert r.status_code == 201, r.text
|
||||
st = r.json()
|
||||
assert st["slug"] == "group-one" and st["tool_ids"] == [t1] and st["description"] == "g1"
|
||||
sid = st["id"]
|
||||
|
||||
# list
|
||||
r = await c.get(f"/v1/projects/{pid}/tool-sets")
|
||||
assert r.status_code == 200 and any(x["id"] == sid for x in r.json())
|
||||
|
||||
# add + remove via the membership endpoints
|
||||
assert (await c.post(f"/v1/projects/{pid}/tool-sets/{sid}/tools/{t2}")).status_code == 204
|
||||
assert set((await c.get(f"/v1/projects/{pid}/tool-sets/{sid}")).json()["tool_ids"]) == {t1, t2}
|
||||
assert (await c.delete(f"/v1/projects/{pid}/tool-sets/{sid}/tools/{t1}")).status_code == 204
|
||||
|
||||
# patch: rename + replace membership
|
||||
r = await c.patch(f"/v1/projects/{pid}/tool-sets/{sid}", json={"name": "Renamed", "tool_ids": [t1, t2]})
|
||||
assert r.json()["slug"] == "renamed" and set(r.json()["tool_ids"]) == {t1, t2}
|
||||
|
||||
# delete
|
||||
assert (await c.delete(f"/v1/projects/{pid}/tool-sets/{sid}")).status_code == 204
|
||||
assert (await c.get(f"/v1/projects/{pid}/tool-sets/{sid}")).status_code == 404
|
||||
|
||||
|
||||
async def _seed_mcp_project(tenant: str, slug: str) -> tuple[str, str, str]:
|
||||
"""Project + two builtin tools; returns (project_id, calc_tool_id, clock_tool_id)."""
|
||||
async with SessionLocal() as s:
|
||||
proj = Project(tenant_id=tenant, name="MCP TS", slug=slug, config={})
|
||||
s.add(proj)
|
||||
await s.flush()
|
||||
ta = Tool(tenant_id=tenant, project_id=proj.id, name="calc", kind="builtin",
|
||||
config={"builtin": "calculator", "description": "c"})
|
||||
tb = Tool(tenant_id=tenant, project_id=proj.id, name="clock", kind="builtin",
|
||||
config={"builtin": "current_time", "description": "t"})
|
||||
s.add_all([ta, tb])
|
||||
await s.commit()
|
||||
for obj in (proj, ta, tb):
|
||||
await s.refresh(obj)
|
||||
return proj.id, ta.id, tb.id
|
||||
|
||||
|
||||
async def _list_names(c: httpx.AsyncClient, path: str) -> set[str]:
|
||||
r = await c.post(path, json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
|
||||
return {t["name"] for t in r.json()["result"]["tools"]}
|
||||
|
||||
|
||||
async def test_mcp_toolset_scoped_exposure():
|
||||
tenant = "t_mcp_ts"
|
||||
pid, a_id, b_id = await _seed_mcp_project(tenant, "mcp-ts")
|
||||
async with SessionLocal() as s:
|
||||
await ToolSetService.create(s, tenant, pid, name="Set A", tool_ids=[a_id])
|
||||
set_b = await ToolSetService.create(s, tenant, pid, name="Set B", tool_ids=[b_id])
|
||||
b_slug, b_set_id = set_b.slug, set_b.id
|
||||
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
# base endpoint = flat union of every EXPOSED set's enabled tools
|
||||
assert await _list_names(c, f"/v1/mcp/{pid}") == {"calc", "clock"}
|
||||
# per-set endpoint = just that set's flat list
|
||||
assert await _list_names(c, f"/v1/mcp/{pid}/toolset/{b_slug}") == {"clock"}
|
||||
assert await _list_names(c, f"/v1/mcp/{pid}/toolset/nope") == set() # unknown slug => empty
|
||||
r = await c.post(f"/v1/mcp/{pid}/toolset/{b_slug}", json={"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
||||
"params": {"name": "clock", "arguments": {}}})
|
||||
assert r.json()["result"]["isError"] is False
|
||||
|
||||
# un-expose Set B -> it drops off both the base surface and its own endpoint
|
||||
async with SessionLocal() as s:
|
||||
await ToolSetService.update(s, await ToolSetService.get(s, tenant, b_set_id), exposed=False)
|
||||
assert await _list_names(c, f"/v1/mcp/{pid}") == {"calc"}
|
||||
assert await _list_names(c, f"/v1/mcp/{pid}/toolset/{b_slug}") == set()
|
||||
r = await c.post(f"/v1/mcp/{pid}", json={"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
||||
"params": {"name": "clock", "arguments": {}}})
|
||||
assert "not exposed" in r.json()["error"]["message"]
|
||||
|
||||
|
||||
async def test_mcp_tool_level_exclusion():
|
||||
"""Everything in an exposed set is published by default; an operator can untick individual
|
||||
tools via project.config.mcp_excluded_tools."""
|
||||
tenant = "t_mcp_excl"
|
||||
pid, a_id, b_id = await _seed_mcp_project(tenant, "mcp-excl")
|
||||
async with SessionLocal() as s:
|
||||
await ToolSetService.create(s, tenant, pid, name="Set", tool_ids=[a_id, b_id])
|
||||
proj = await s.get(Project, pid)
|
||||
proj.config = {"mcp_excluded_tools": [b_id]} # untick 'clock'
|
||||
await s.commit()
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
assert await _list_names(c, f"/v1/mcp/{pid}") == {"calc"}
|
||||
|
||||
|
||||
async def test_mcp_no_toolsets_exposes_nothing():
|
||||
tenant = "t_mcp_none"
|
||||
pid, a_id, _b = await _seed_mcp_project(tenant, "mcp-none")
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
# No tool sets => nothing published (there are no loose / "direct" tools).
|
||||
assert await _list_names(c, f"/v1/mcp/{pid}") == set()
|
||||
# once a tool is placed in an exposed set, it appears
|
||||
async with SessionLocal() as s:
|
||||
await ToolSetService.create(s, tenant, pid, name="General", tool_ids=[a_id])
|
||||
assert await _list_names(c, f"/v1/mcp/{pid}") == {"calc"}
|
||||
|
||||
|
||||
async def test_mcp_session_token_authorizes_as_end_user():
|
||||
"""A project-scoped Forge session token authenticates an MCP caller AS its end_user
|
||||
(the portable per-user identity channel), alongside the shared project key."""
|
||||
from forge.security import create_session_token
|
||||
|
||||
tenant = "t_mcp_sess"
|
||||
async with SessionLocal() as s:
|
||||
proj = Project(tenant_id=tenant, name="Sess", slug="mcp-sess", config={"mcp_api_key": "shared-key"})
|
||||
s.add(proj)
|
||||
await s.flush()
|
||||
s.add(Tool(tenant_id=tenant, project_id=proj.id, name="calc", kind="builtin",
|
||||
config={"builtin": "calculator", "description": "c"}))
|
||||
await s.commit()
|
||||
await s.refresh(proj)
|
||||
pid = proj.id
|
||||
|
||||
good = create_session_token(tenant_id=tenant, project_id=pid, end_user={"id": "u1", "entitlements": ["billing"]})
|
||||
wrong_project = create_session_token(tenant_id=tenant, project_id="another", end_user={"id": "u2"})
|
||||
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
# shared key -> authorized (no per-user identity)
|
||||
assert (await c.post(f"/v1/mcp/{pid}", headers={"Authorization": "Bearer shared-key"}, json=body)).status_code == 200
|
||||
# project-scoped session token -> authorized as that end user
|
||||
assert (await c.post(f"/v1/mcp/{pid}", headers={"Authorization": f"Bearer {good}"}, json=body)).status_code == 200
|
||||
# session token scoped to a different project -> rejected
|
||||
assert (await c.post(f"/v1/mcp/{pid}", headers={"Authorization": f"Bearer {wrong_project}"}, json=body)).status_code == 401
|
||||
# no credential -> rejected (a key is configured)
|
||||
assert (await c.post(f"/v1/mcp/{pid}", json=body)).status_code == 401
|
||||
|
||||
|
||||
async def test_mcp_personal_access_token_authorizes_as_user():
|
||||
"""A per-user Personal Access Token (forge_pat_) authenticates an MCP client as that user."""
|
||||
from forge.services.apikeys import ApiKeyService
|
||||
|
||||
tenant = "t_mcp_pat"
|
||||
async with SessionLocal() as s:
|
||||
proj = Project(tenant_id=tenant, name="PAT", slug="mcp-pat", config={"mcp_api_key": "shared-key"})
|
||||
s.add(proj)
|
||||
await s.flush()
|
||||
s.add(Tool(tenant_id=tenant, project_id=proj.id, name="calc", kind="builtin",
|
||||
config={"builtin": "calculator", "description": "c"}))
|
||||
user = User(tenant_id=tenant, email="pat-user@example.com", role="editor", status="active")
|
||||
s.add(user)
|
||||
await s.commit()
|
||||
await s.refresh(proj)
|
||||
await s.refresh(user)
|
||||
pid = proj.id
|
||||
_k1, pat = await ApiKeyService.create_personal(s, tenant_id=tenant, user_id=user.id, name="t", project_id=pid)
|
||||
key_id = _k1.id
|
||||
_k2, pat_other = await ApiKeyService.create_personal(s, tenant_id=tenant, user_id=user.id, name="t2", project_id="another-project")
|
||||
|
||||
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
# a project-scoped PAT authorizes
|
||||
assert (await c.post(f"/v1/mcp/{pid}", headers={"Authorization": f"Bearer {pat}"}, json=body)).status_code == 200
|
||||
# a PAT scoped to a different project is rejected here
|
||||
assert (await c.post(f"/v1/mcp/{pid}", headers={"Authorization": f"Bearer {pat_other}"}, json=body)).status_code == 401
|
||||
# once revoked, the PAT no longer authorizes
|
||||
async with SessionLocal() as s:
|
||||
await ApiKeyService.revoke_personal(s, tenant_id=tenant, user_id=user.id, key_id=key_id)
|
||||
assert (await c.post(f"/v1/mcp/{pid}", headers={"Authorization": f"Bearer {pat}"}, json=body)).status_code == 401
|
||||
|
||||
|
||||
async def test_mcp_token_api_crud():
|
||||
"""The user-facing PAT endpoints mint / list / revoke tokens, and a minted token works on MCP."""
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
reg = await c.post("/v1/auth/register", json={"email": f"u{uuid.uuid4().hex[:10]}@example.com", "password": "supersecret1"})
|
||||
assert reg.status_code == 201, reg.text
|
||||
c.headers["Authorization"] = f"Bearer {reg.json()['access_token']}"
|
||||
pid = (await c.post("/v1/projects", json={"name": "PAT API", "slug": "pat-api"})).json()["id"]
|
||||
# lock the MCP surface behind a key so credential checks are meaningful
|
||||
await c.patch(f"/v1/projects/{pid}", json={"config": {"mcp_api_key": "k"}})
|
||||
|
||||
r = await c.post(f"/v1/projects/{pid}/mcp-tokens", json={"name": "my token"})
|
||||
assert r.status_code == 201, r.text
|
||||
tok = r.json()
|
||||
assert tok["token"].startswith("forge_pat_") and tok["status"] == "active"
|
||||
|
||||
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
|
||||
# the freshly minted PAT authenticates against the MCP endpoint (auth is enforced by the key)
|
||||
assert (await c.post(f"/v1/mcp/{pid}", json=body)).status_code == 401
|
||||
pat_headers = {"Authorization": f"Bearer {tok['token']}"}
|
||||
assert (await c.post(f"/v1/mcp/{pid}", headers=pat_headers, json=body)).status_code == 200
|
||||
|
||||
# listed without the plaintext, then revoked
|
||||
lst = (await c.get(f"/v1/projects/{pid}/mcp-tokens")).json()
|
||||
assert any(t["id"] == tok["id"] and t.get("token") is None for t in lst)
|
||||
assert (await c.delete(f"/v1/projects/{pid}/mcp-tokens/{tok['id']}")).status_code == 204
|
||||
assert (await c.post(f"/v1/mcp/{pid}", headers=pat_headers, json=body)).status_code == 401
|
||||
|
||||
|
||||
async def test_connector_role_is_mcp_only():
|
||||
"""A 'connector' user can manage their own MCP tokens but cannot mutate project resources."""
|
||||
from forge.security import create_access_token
|
||||
|
||||
tenant = "t_conn_role"
|
||||
async with SessionLocal() as s:
|
||||
u = User(tenant_id=tenant, email="connector@example.com", role="connector", status="active")
|
||||
proj = Project(tenant_id=tenant, name="Conn", slug="conn-p", config={})
|
||||
s.add_all([u, proj])
|
||||
await s.commit()
|
||||
await s.refresh(u)
|
||||
await s.refresh(proj)
|
||||
uid, pid = u.id, proj.id
|
||||
|
||||
token = create_access_token(user_id=uid, tenant_id=tenant, role="connector")
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
|
||||
c.headers["Authorization"] = f"Bearer {token}"
|
||||
# cannot create/mutate project resources (needs editor+)
|
||||
assert (await c.post("/v1/projects", json={"name": "X", "slug": "x-conn"})).status_code == 403
|
||||
assert (await c.post(f"/v1/projects/{pid}/tool-sets", json={"name": "S"})).status_code == 403
|
||||
# but can mint their own MCP personal access token
|
||||
r = await c.post(f"/v1/projects/{pid}/mcp-tokens", json={"name": "my token"})
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["token"].startswith("forge_pat_")
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Code-tool (RestrictedPython sandbox) and SQL-tool (read-only) tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from forge.services.runtime import make_runtime_ctx
|
||||
from forge.tools.code import CodeToolError, execute_code, run_code
|
||||
from forge.tools.materialize import materialize_tool
|
||||
from forge.tools.sql import SqlToolError, execute_sql
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_code_tools(monkeypatch):
|
||||
# Code tools are OFF by default in prod-safe config (unsandboxed RestrictedPython is not
|
||||
# an isolation boundary - audit S5); these tests exercise the feature, so opt in.
|
||||
from forge.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "enable_code_tools", True)
|
||||
|
||||
|
||||
# --- code tool ---
|
||||
|
||||
|
||||
async def test_code_tool_main_returns_value():
|
||||
cfg = {"name": "adder", "kind": "code", "language": "python",
|
||||
"source": "def main(a, b):\n return a + b\n",
|
||||
"args_schema": {"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}, "required": ["a", "b"]}}
|
||||
assert await execute_code(cfg, {"a": 2, "b": 5}) == 7
|
||||
|
||||
|
||||
async def test_code_tool_allows_safe_import():
|
||||
assert await execute_code({"source": "import math\ndef main(x):\n return math.sqrt(x)\n"}, {"x": 9}) == 3.0
|
||||
|
||||
|
||||
async def test_code_tool_blocks_unsafe_import():
|
||||
with pytest.raises(CodeToolError):
|
||||
run_code("import os\ndef main():\n return os.getcwd()\n", {})
|
||||
|
||||
|
||||
async def test_code_tool_blocks_dunder_escape():
|
||||
with pytest.raises(CodeToolError):
|
||||
run_code("def main():\n return ().__class__.__bases__\n", {})
|
||||
|
||||
|
||||
async def test_code_tool_materializes_as_structured_tool():
|
||||
cfg = {"name": "upper", "kind": "code",
|
||||
"source": "def main(s):\n return s.upper()\n",
|
||||
"args_schema": {"properties": {"s": {"type": "string"}}, "required": ["s"]}}
|
||||
tool = materialize_tool(cfg, make_runtime_ctx("t", "p"))
|
||||
assert await tool.ainvoke({"s": "hi"}) == "HI"
|
||||
|
||||
|
||||
# --- sql tool (read-only) ---
|
||||
|
||||
|
||||
async def test_sql_tool_rejects_writes():
|
||||
cfg = {"name": "q", "kind": "sql", "query": "DELETE FROM users", "connection_url": "sqlite+aiosqlite:///:memory:"}
|
||||
with pytest.raises(SqlToolError):
|
||||
await execute_sql(cfg, {}, tenant_id="t", project_id="p")
|
||||
|
||||
|
||||
async def test_sql_tool_rejects_multi_statement():
|
||||
cfg = {"name": "q", "kind": "sql", "query": "SELECT 1; DROP TABLE users", "connection_url": "sqlite+aiosqlite:///:memory:"}
|
||||
with pytest.raises(SqlToolError):
|
||||
await execute_sql(cfg, {}, tenant_id="t", project_id="p")
|
||||
|
||||
|
||||
async def test_sql_tool_reads_rows(tmp_path):
|
||||
import sqlite3
|
||||
|
||||
db = tmp_path / "demo.db"
|
||||
con = sqlite3.connect(db)
|
||||
con.executescript("CREATE TABLE t(id INTEGER, name TEXT); INSERT INTO t VALUES (1,'a'),(2,'b');")
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
cfg = {"name": "q", "kind": "sql", "connection_url": f"sqlite+aiosqlite:///{db.as_posix()}",
|
||||
"query": "SELECT id, name FROM t WHERE id = :id",
|
||||
"args_schema": {"properties": {"id": {"type": "integer"}}, "required": ["id"]}}
|
||||
res = await execute_sql(cfg, {"id": 2}, tenant_id="t", project_id="p")
|
||||
assert res["rows"] == [{"id": 2, "name": "b"}]
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Hardening tests for the tool executors (robustness-audit fixes).
|
||||
|
||||
Covers: retry semantics (idempotency + transient-only default), the shared entitlement gate for
|
||||
every non-REST kind, reliability parity (rate_limit/cache) for graphql/sql/code via the shared
|
||||
wrapper, JMESPath projection error markers, SQL read-only/limit/cell-cap hardening, GraphQL
|
||||
in-band error handling + operationName, mcp being creatable, REST multipart + download-size guard,
|
||||
and production-default trace redaction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from forge.services.runtime import make_runtime_ctx
|
||||
from forge.tools import rest as rest_mod
|
||||
from forge.tools.graphql import GraphQLToolError, execute_graphql
|
||||
from forge.tools.materialize import materialize_tool
|
||||
from forge.tools.projection import project_response
|
||||
from forge.tools.rest import _resolve_retry, _retry_types, _should_retry, execute_rest
|
||||
from forge.tools.sql import SqlToolError, execute_sql
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_code_tools(monkeypatch):
|
||||
from forge.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "enable_code_tools", True)
|
||||
|
||||
|
||||
def _rest_cfg(**extra) -> dict:
|
||||
return {
|
||||
"name": f"t_{uuid.uuid4().hex[:8]}",
|
||||
"kind": "rest_api",
|
||||
"request": {"method": "GET", "url_template": "https://api.acme.dev/v2/ping", "fields": []},
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
# --- Finding 1: retry semantics (transient-only default, idempotency gating, schema default) ----
|
||||
|
||||
|
||||
def test_should_retry_gates_on_idempotency_and_status():
|
||||
transient = _retry_types([])
|
||||
# 4xx is never retried by default (regression: HTTPError superclass used to retry it).
|
||||
resp4 = httpx.Response(404, request=httpx.Request("GET", "https://x"))
|
||||
err4 = httpx.HTTPStatusError("nf", request=resp4.request, response=resp4)
|
||||
assert _should_retry(err4, transient, True, "GET", {}) is False
|
||||
# 5xx IS retried on an idempotent method by the default classification...
|
||||
resp5 = httpx.Response(503, request=httpx.Request("GET", "https://x"))
|
||||
err5 = httpx.HTTPStatusError("boom", request=resp5.request, response=resp5)
|
||||
assert _should_retry(err5, transient, True, "GET", {}) is True
|
||||
# ...but NOT on a non-idempotent POST unless explicitly opted in.
|
||||
assert _should_retry(err5, transient, True, "POST", {}) is False
|
||||
assert _should_retry(err5, transient, True, "POST", {"retry_non_idempotent": True}) is True
|
||||
|
||||
|
||||
def test_resolve_retry_defaults_align_with_schema():
|
||||
# No retry block => opt-out (no retries), preserving historic behavior.
|
||||
assert _resolve_retry({})[0] == 0
|
||||
# A retry block present but max_retries omitted => schema default of 2.
|
||||
assert _resolve_retry({"retry": {}})[0] == 2
|
||||
assert _resolve_retry({"retry": {"max_retries": 5}})[0] == 5
|
||||
|
||||
|
||||
async def test_default_retry_does_not_retry_4xx():
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(req):
|
||||
calls["n"] += 1
|
||||
return httpx.Response(404, json={"e": "nope"})
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
cfg = _rest_cfg(retry={"max_retries": 3, "initial_delay": 0.001, "jitter": False})
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await execute_rest(cfg, {}, tenant_id="t", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
assert calls["n"] == 1 # a permanent 4xx is not retried
|
||||
|
||||
|
||||
async def test_default_retry_retries_transient_5xx_on_get():
|
||||
state = {"n": 0}
|
||||
|
||||
def handler(req):
|
||||
state["n"] += 1
|
||||
return httpx.Response(500 if state["n"] == 1 else 200, json={"ok": state["n"]})
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
cfg = _rest_cfg(retry={"max_retries": 2, "initial_delay": 0.001, "jitter": False}) # no retry_on
|
||||
res = await execute_rest(cfg, {}, tenant_id="t", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
assert state["n"] == 2 and res["status"] == 200
|
||||
|
||||
|
||||
async def test_post_5xx_not_retried_by_default():
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(req):
|
||||
calls["n"] += 1
|
||||
return httpx.Response(500, json={})
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
cfg = _rest_cfg(retry={"max_retries": 3, "initial_delay": 0.001, "jitter": False})
|
||||
cfg["request"]["method"] = "POST"
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await execute_rest(cfg, {}, tenant_id="t", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
assert calls["n"] == 1 # non-idempotent POST is not auto-retried
|
||||
|
||||
|
||||
# --- Finding 4: broken JMESPath projection -> structured error marker (not silent full payload) --
|
||||
|
||||
|
||||
def test_broken_jmespath_returns_error_marker():
|
||||
data = {"secret": "x" * 100, "items": [1, 2, 3]}
|
||||
out = project_response(data, {"projection_jmespath": "items[?"}) # malformed expression
|
||||
assert isinstance(out, dict) and out.get("error") == "projection_error"
|
||||
assert "expression" in out and out != data # did NOT masquerade as the full payload
|
||||
|
||||
|
||||
def test_valid_jmespath_missing_key_is_not_error():
|
||||
out = project_response({"a": 1}, {"projection_jmespath": "nope.missing"})
|
||||
assert out is None # a valid expression selecting nothing is not an error
|
||||
|
||||
|
||||
# --- Finding 5: SQL read-only / limit / cell-cap hardening ---------------------------------------
|
||||
|
||||
|
||||
def _sqlite_url(tmp_path, rows=5) -> str:
|
||||
db = tmp_path / f"h_{uuid.uuid4().hex[:6]}.db"
|
||||
con = sqlite3.connect(db)
|
||||
con.executescript("CREATE TABLE t(id INTEGER, name TEXT);")
|
||||
con.executemany("INSERT INTO t VALUES (?, ?)", [(i, f"n{i}") for i in range(1, rows + 1)])
|
||||
con.commit()
|
||||
con.close()
|
||||
return f"sqlite+aiosqlite:///{db.as_posix()}"
|
||||
|
||||
|
||||
async def test_sql_forbids_into_outfile(tmp_path):
|
||||
cfg = {"name": "q", "kind": "sql", "connection_url": _sqlite_url(tmp_path),
|
||||
"query": "SELECT * FROM t INTO OUTFILE '/tmp/x'"}
|
||||
with pytest.raises(SqlToolError):
|
||||
await execute_sql(cfg, {}, tenant_id="t", project_id="p")
|
||||
|
||||
|
||||
async def test_sql_streaming_truncation_is_accurate(tmp_path):
|
||||
url = _sqlite_url(tmp_path, rows=5)
|
||||
over = await execute_sql({"name": "q", "kind": "sql", "connection_url": url,
|
||||
"query": "SELECT id FROM t ORDER BY id", "max_rows": 3}, {},
|
||||
tenant_id="t", project_id="p")
|
||||
assert over["row_count"] == 3 and over["truncated"] is True
|
||||
exact = await execute_sql({"name": "q", "kind": "sql", "connection_url": url,
|
||||
"query": "SELECT id FROM t ORDER BY id", "max_rows": 5}, {},
|
||||
tenant_id="t", project_id="p")
|
||||
assert exact["row_count"] == 5 and exact["truncated"] is False # exactly max_rows is not truncated
|
||||
|
||||
|
||||
async def test_sql_caps_large_cell(tmp_path):
|
||||
cfg = {"name": "q", "kind": "sql", "connection_url": _sqlite_url(tmp_path, rows=1),
|
||||
"query": "SELECT printf('%.*c', 30000, 'x') AS big"}
|
||||
res = await execute_sql(cfg, {}, tenant_id="t", project_id="p")
|
||||
big = res["rows"][0]["big"]
|
||||
assert len(big) < 30000 and "truncated" in big
|
||||
|
||||
|
||||
# --- Finding 7: GraphQL in-band errors + operationName -------------------------------------------
|
||||
|
||||
|
||||
async def test_graphql_errors_with_null_data_raise():
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(
|
||||
lambda r: httpx.Response(200, json={"data": None, "errors": [{"message": "boom"}]})
|
||||
))
|
||||
cfg = {"name": "g", "kind": "graphql", "endpoint": "https://api.acme.dev/graphql", "query": "{ me { id } }"}
|
||||
with pytest.raises(GraphQLToolError, match="boom"):
|
||||
await execute_graphql(cfg, {}, tenant_id="t", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_graphql_partial_data_passes_through():
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(
|
||||
lambda r: httpx.Response(200, json={"data": {"me": {"id": "1"}}, "errors": [{"message": "field x failed"}]})
|
||||
))
|
||||
cfg = {"name": "g", "kind": "graphql", "endpoint": "https://api.acme.dev/graphql", "query": "{ me { id } }"}
|
||||
res = await execute_graphql(cfg, {}, tenant_id="t", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
assert res["raw"]["data"] == {"me": {"id": "1"}} # partial success is valid GraphQL
|
||||
|
||||
|
||||
async def test_graphql_sends_operation_name():
|
||||
seen = {}
|
||||
|
||||
def handler(req):
|
||||
import json as _j
|
||||
|
||||
seen.update(_j.loads(req.content))
|
||||
return httpx.Response(200, json={"data": {"ok": True}})
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
cfg = {"name": "g", "kind": "graphql", "endpoint": "https://api.acme.dev/graphql",
|
||||
"query": "query A { a } query B { b }", "operation_name": "B"}
|
||||
await execute_graphql(cfg, {}, tenant_id="t", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
assert seen.get("operationName") == "B"
|
||||
|
||||
|
||||
# --- Finding 2: entitlement gate for every non-REST kind (deny independently of the LLM) ---------
|
||||
|
||||
|
||||
def _ctx_without(entitlement="billing:read"):
|
||||
ctx = make_runtime_ctx("t", "p")
|
||||
ctx.end_user = {"id": "u1", "entitlements": []} # user lacks the required entitlement
|
||||
return ctx
|
||||
|
||||
|
||||
async def test_graphql_entitlement_denied():
|
||||
ctx = _ctx_without()
|
||||
cfg = {"name": "g", "kind": "graphql", "endpoint": "https://api.acme.dev/graphql",
|
||||
"query": "{ me { id } }", "required_entitlements": ["billing:read"]}
|
||||
tool = materialize_tool(cfg, ctx)
|
||||
out = await tool.ainvoke({})
|
||||
assert "Not permitted" in out # denied before any network call
|
||||
|
||||
|
||||
async def test_sql_entitlement_denied(tmp_path):
|
||||
ctx = _ctx_without()
|
||||
cfg = {"name": "q", "kind": "sql", "connection_url": _sqlite_url(tmp_path),
|
||||
"query": "SELECT id FROM t", "required_entitlements": ["billing:read"]}
|
||||
tool = materialize_tool(cfg, ctx)
|
||||
out = await tool.ainvoke({})
|
||||
assert "Not permitted" in out
|
||||
|
||||
|
||||
async def test_code_entitlement_denied():
|
||||
ctx = _ctx_without()
|
||||
cfg = {"name": "c", "kind": "code", "language": "python",
|
||||
"source": "def main():\n return 1\n", "required_entitlements": ["billing:read"]}
|
||||
tool = materialize_tool(cfg, ctx)
|
||||
out = await tool.ainvoke({})
|
||||
assert "Not permitted" in out
|
||||
|
||||
|
||||
async def test_component_entitlement_denied():
|
||||
from forge.tools.components import build_component_tool
|
||||
|
||||
ctx = _ctx_without()
|
||||
cfg = {"id": "c1", "name": "chart", "props_schema": {}, "required_entitlements": ["billing:read"]}
|
||||
tool = build_component_tool(cfg, ctx)
|
||||
out = await tool.ainvoke({})
|
||||
assert "Not permitted" in out
|
||||
|
||||
|
||||
async def test_entitled_user_is_allowed(tmp_path):
|
||||
ctx = make_runtime_ctx("t", "p")
|
||||
ctx.end_user = {"id": "u2", "entitlements": ["billing:read"]}
|
||||
cfg = {"name": "q", "kind": "sql", "connection_url": _sqlite_url(tmp_path, rows=2),
|
||||
"query": "SELECT id FROM t ORDER BY id", "required_entitlements": ["billing:read"]}
|
||||
tool = materialize_tool(cfg, ctx)
|
||||
out = await tool.ainvoke({})
|
||||
assert [r["id"] for r in out] == [1, 2] # entitled -> query actually runs
|
||||
|
||||
|
||||
# --- Finding 3: reliability (rate_limit + cache) reach graphql/sql/code via the shared wrapper ---
|
||||
|
||||
|
||||
async def test_sql_rate_limit_via_wrapper(tmp_path):
|
||||
ctx = make_runtime_ctx(f"t_{uuid.uuid4().hex[:6]}", "p")
|
||||
cfg = {"name": f"q_{uuid.uuid4().hex[:6]}", "kind": "sql", "connection_url": _sqlite_url(tmp_path),
|
||||
"query": "SELECT id FROM t", "rate_limit": {"per_minute": 1}}
|
||||
tool = materialize_tool(cfg, ctx)
|
||||
await tool.ainvoke({})
|
||||
with pytest.raises(RuntimeError, match="rate limit"):
|
||||
await tool.ainvoke({})
|
||||
|
||||
|
||||
async def test_sql_cache_via_wrapper(tmp_path):
|
||||
url = _sqlite_url(tmp_path, rows=1)
|
||||
ctx = make_runtime_ctx("t", "p")
|
||||
cfg = {"name": f"q_{uuid.uuid4().hex[:6]}", "kind": "sql", "connection_url": url,
|
||||
"query": "SELECT id FROM t ORDER BY id", "cache": {"ttl_seconds": 60}}
|
||||
tool = materialize_tool(cfg, ctx)
|
||||
first = await tool.ainvoke({})
|
||||
# Mutate the DB behind the cache; a cache hit must still return the ORIGINAL rows.
|
||||
path = url.split(":///", 1)[1]
|
||||
con = sqlite3.connect(path)
|
||||
con.execute("INSERT INTO t VALUES (99, 'new')")
|
||||
con.commit()
|
||||
con.close()
|
||||
second = await tool.ainvoke({})
|
||||
assert first == second == [{"id": 1}] # served from cache, not re-queried
|
||||
|
||||
|
||||
# --- Finding 9: kind:"mcp" is creatable (materialize returns a deferred None, does not raise) -----
|
||||
|
||||
|
||||
def test_materialize_mcp_returns_none_instead_of_raising():
|
||||
ctx = make_runtime_ctx("t", "p")
|
||||
cfg = {"name": "gh", "kind": "mcp", "mcp_client_id": "srv1", "remote_tool_name": "list_issues"}
|
||||
assert materialize_tool(cfg, ctx) is None
|
||||
|
||||
|
||||
def test_materialize_unknown_kind_still_raises():
|
||||
ctx = make_runtime_ctx("t", "p")
|
||||
with pytest.raises(ValueError, match="Unknown tool kind"):
|
||||
materialize_tool({"name": "x", "kind": "bogus"}, ctx)
|
||||
|
||||
|
||||
# --- Finding 6: REST multipart encoding + download-size guard ------------------------------------
|
||||
|
||||
|
||||
async def test_multipart_body_encoding_sends_form_data():
|
||||
seen = {}
|
||||
|
||||
def handler(req):
|
||||
seen["ct"] = req.headers.get("content-type", "")
|
||||
seen["body"] = req.content.decode("utf-8", "replace")
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
cfg = _rest_cfg()
|
||||
cfg["request"] = {
|
||||
"method": "POST", "url_template": "https://api.acme.dev/upload", "body_encoding": "multipart",
|
||||
"fields": [{"path": "title", "type": "string", "in": "body"}],
|
||||
}
|
||||
await execute_rest(cfg, {"title": "hello"}, tenant_id="t", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
assert seen["ct"].startswith("multipart/form-data") and "hello" in seen["body"]
|
||||
|
||||
|
||||
async def test_download_size_guard_marks_oversized_body(monkeypatch):
|
||||
monkeypatch.setattr(rest_mod, "_MAX_DOWNLOAD_BYTES", 50)
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(
|
||||
lambda r: httpx.Response(200, json={"blob": "x" * 500})
|
||||
))
|
||||
res = await execute_rest(_rest_cfg(), {}, tenant_id="t", project_id="p", client=client)
|
||||
await client.aclose()
|
||||
assert res["raw"]["error"] == "response_too_large" and res["raw"]["bytes"] > 50
|
||||
|
||||
|
||||
# --- Finding 10: trace I/O redaction defaults ON for a production install --------------------------
|
||||
|
||||
|
||||
def test_trace_redaction_defaults_on_in_production(monkeypatch):
|
||||
from forge.config import settings
|
||||
from forge.tracing import tool_io
|
||||
|
||||
monkeypatch.setattr(settings, "trace_tool_io_redact", False)
|
||||
# Dev + flag off => values pass through.
|
||||
monkeypatch.setattr(settings, "environment", "development")
|
||||
assert tool_io.redact_headers({"Authorization": "Bearer secret"})["Authorization"] == "Bearer secret"
|
||||
# Production + flag off => sensitive values are masked anyway.
|
||||
monkeypatch.setattr(settings, "environment", "production")
|
||||
masked = tool_io.redact_headers({"Authorization": "Bearer secret"})["Authorization"]
|
||||
assert masked != "Bearer secret" and "secret" not in masked
|
||||
@@ -0,0 +1,42 @@
|
||||
"""HITL interrupts are control flow, not failures.
|
||||
|
||||
When HumanInTheLoopMiddleware calls interrupt() it RAISES a GraphInterrupt (a GraphBubbleUp) to
|
||||
suspend the graph for approval. That raised signal reaches the tracer's on_*_error callbacks, so
|
||||
the tracer must recognize it and NOT record it as a span error - otherwise a run paused for human
|
||||
approval renders as a crash (the agent + HITL spans showing red), which is exactly the misleading
|
||||
trace this guards against. The run's own status is already a first-class `interrupted`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langgraph.errors import GraphInterrupt
|
||||
|
||||
from forge.tracing.tracer import ForgeTracer
|
||||
|
||||
|
||||
def test_chain_interrupt_is_not_a_span_error():
|
||||
tr = ForgeTracer()
|
||||
tr.on_chain_start({"name": "agent_1"}, {}, run_id="agent-1")
|
||||
tr.on_chain_error(GraphInterrupt(), run_id="agent-1") # HITL pause bubbling up
|
||||
sp = tr.spans["agent-1"]
|
||||
assert sp.error is None, "an interrupt must not mark the span errored"
|
||||
assert sp.attributes.get("interrupted") is True
|
||||
assert sp.end is not None # span is still closed (latency captured)
|
||||
|
||||
|
||||
def test_tool_interrupt_is_not_a_span_error():
|
||||
tr = ForgeTracer()
|
||||
tr.on_tool_start({"name": "get_weather"}, "args", run_id="tool-1")
|
||||
tr.on_tool_error(GraphInterrupt(), run_id="tool-1")
|
||||
sp = tr.spans["tool-1"]
|
||||
assert sp.error is None
|
||||
assert sp.attributes.get("interrupted") is True
|
||||
|
||||
|
||||
def test_real_error_is_still_recorded():
|
||||
tr = ForgeTracer()
|
||||
tr.on_chain_start({"name": "some_node"}, {}, run_id="node-1")
|
||||
tr.on_chain_error(ValueError("boom"), run_id="node-1")
|
||||
sp = tr.spans["node-1"]
|
||||
assert sp.error == "boom"
|
||||
assert not sp.attributes.get("interrupted")
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Trigger sync + scheduling + webhook dispatch (end-to-end via services)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import Trigger, Workflow
|
||||
from forge.services.dispatch import dispatch_trigger
|
||||
from forge.services.runs import RunService
|
||||
from forge.services.triggers import TriggerService
|
||||
|
||||
_WEBHOOK_WF = {
|
||||
"id": "wf_hook", "version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
|
||||
"entry_node": "hook",
|
||||
"nodes": [
|
||||
{"id": "hook", "type": "webhook_in", "config": {"message_path": "text"}},
|
||||
{"id": "agent", "type": "agent", "config": {"flavor": "agent", "model": "fake:Done."}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [{"source": "hook", "target": "agent"}, {"source": "agent", "target": "end"}],
|
||||
}
|
||||
|
||||
|
||||
async def _make_wf(tenant="t_trig", project="p_trig") -> Workflow:
|
||||
async with SessionLocal() as s:
|
||||
wf = Workflow(tenant_id=tenant, project_id=project, name="Hooked", executable=_WEBHOOK_WF, status="active")
|
||||
s.add(wf)
|
||||
await s.commit()
|
||||
await s.refresh(wf)
|
||||
await TriggerService.sync_from_workflow(s, wf)
|
||||
return wf
|
||||
|
||||
|
||||
async def test_sync_creates_webhook_trigger_with_key():
|
||||
wf = await _make_wf()
|
||||
async with SessionLocal() as s:
|
||||
trigs = (await s.execute(Trigger.__table__.select().where(Trigger.workflow_id == wf.id))).fetchall()
|
||||
assert len(trigs) == 1
|
||||
async with SessionLocal() as s:
|
||||
t = await TriggerService.by_key(s, trigs[0].key)
|
||||
assert t is not None and t.kind == "webhook_in" and t.key
|
||||
|
||||
|
||||
def test_build_input_message_path_extracts_field():
|
||||
t = Trigger(tenant_id="t", project_id="p", workflow_id="w", node_id="hook", kind="webhook_in", config={"message_path": "text"})
|
||||
assert TriggerService.build_input(t, {"text": "hello there"}) == {"messages": [{"role": "user", "content": "hello there"}]}
|
||||
|
||||
|
||||
def test_build_input_schedule_uses_config_message():
|
||||
t = Trigger(tenant_id="t", project_id="p", workflow_id="w", node_id="s", kind="schedule", config={"message": "tick"})
|
||||
assert TriggerService.build_input(t, None)["messages"][0]["content"] == "tick"
|
||||
|
||||
|
||||
def test_is_due_interval():
|
||||
t = Trigger(tenant_id="t", project_id="p", workflow_id="w", node_id="s", kind="schedule", config={"every_minutes": 10}, enabled=True)
|
||||
assert TriggerService.is_due(t, datetime.utcnow()) is True # never fired -> due
|
||||
t.last_fired_at = datetime.utcnow()
|
||||
assert TriggerService.is_due(t, datetime.utcnow()) is False
|
||||
t.last_fired_at = datetime.utcnow() - timedelta(minutes=11)
|
||||
assert TriggerService.is_due(t, datetime.utcnow()) is True
|
||||
|
||||
|
||||
async def test_dispatch_webhook_runs_workflow():
|
||||
wf = await _make_wf("t_d", "p_d")
|
||||
async with SessionLocal() as s:
|
||||
trig = (await s.execute(Trigger.__table__.select().where(Trigger.workflow_id == wf.id))).fetchone()
|
||||
trigger = await TriggerService.by_key(s, trig.key)
|
||||
rs = RunService(checkpointer=InMemorySaver())
|
||||
result = await dispatch_trigger(rs, trigger, {"text": "ping"})
|
||||
assert result.get("answer") == "Done." and result.get("status") == "done"
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Validate the workflow validator: schema refs, per-node config, structural rules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
from forge.services.validation import validate_workflow
|
||||
|
||||
GOOD = {
|
||||
"id": "wf_ok",
|
||||
"version": 1,
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"},
|
||||
"intent": {"type": "str", "reducer": "last"}},
|
||||
"entry_node": "start",
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "config": {}},
|
||||
{"id": "route", "type": "router", "config": {
|
||||
"expression": "intent", "cases": {"billing": "billing_agent"}, "default": "billing_agent"}},
|
||||
{"id": "billing_agent", "type": "agent", "config": {
|
||||
"flavor": "agent", "model": "anthropic:claude-sonnet-4-6",
|
||||
"middleware": [{"type": "summarization", "config": {"trigger": ["tokens", 4000]}}]}},
|
||||
{"id": "end", "type": "end", "config": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"source": "start", "target": "route"},
|
||||
{"source": "billing_agent", "target": "end"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_good_workflow_passes():
|
||||
res = validate_workflow(GOOD)
|
||||
assert res.valid, res.errors
|
||||
|
||||
|
||||
def test_unknown_node_type_flagged():
|
||||
wf = copy.deepcopy(GOOD)
|
||||
wf["nodes"][0]["type"] = "frobnicate"
|
||||
res = validate_workflow(wf)
|
||||
assert not res.valid
|
||||
assert any("frobnicate" in e["message"] for e in res.errors)
|
||||
|
||||
|
||||
def test_bad_node_config_flagged_with_pointer():
|
||||
wf = copy.deepcopy(GOOD)
|
||||
# agent requires `model`; remove it
|
||||
del wf["nodes"][2]["config"]["model"]
|
||||
res = validate_workflow(wf)
|
||||
assert not res.valid
|
||||
assert any(e["pointer"].startswith("/nodes/2/config") for e in res.errors), res.errors
|
||||
|
||||
|
||||
def test_unknown_middleware_type_flagged():
|
||||
wf = copy.deepcopy(GOOD)
|
||||
wf["nodes"][2]["config"]["middleware"] = [{"type": "does_not_exist", "config": {}}]
|
||||
res = validate_workflow(wf)
|
||||
assert not res.valid
|
||||
assert any("does_not_exist" in e["message"] for e in res.errors)
|
||||
|
||||
|
||||
def test_edge_to_unknown_node_flagged():
|
||||
wf = copy.deepcopy(GOOD)
|
||||
wf["edges"].append({"source": "billing_agent", "target": "ghost"})
|
||||
res = validate_workflow(wf)
|
||||
assert not res.valid
|
||||
assert any("ghost" in e["message"] for e in res.errors)
|
||||
|
||||
|
||||
def test_orphan_node_flagged():
|
||||
wf = copy.deepcopy(GOOD)
|
||||
wf["nodes"].append({"id": "lonely", "type": "llm", "config": {"model": "fake:x", "prompt": "hi"}})
|
||||
res = validate_workflow(wf)
|
||||
assert not res.valid
|
||||
assert any("lonely" in e["message"] and "unreachable" in e["message"] for e in res.errors)
|
||||
|
||||
|
||||
def test_no_path_to_end_flagged():
|
||||
wf = copy.deepcopy(GOOD)
|
||||
wf["edges"] = [e for e in wf["edges"] if e["target"] != "end"] # cut the only END path
|
||||
res = validate_workflow(wf)
|
||||
assert not res.valid
|
||||
assert any("END" in e["message"] for e in res.errors)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Version-history retention resolution (item 11).
|
||||
|
||||
The console Settings > Versioning panel writes `version_history_limit` into project.config;
|
||||
snapshot()/prune must honor it, with precedence project.config > tenant.settings > global.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from forge.config import settings
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import Project, Workflow
|
||||
from forge.services.versions import VersionService, _limit_for
|
||||
|
||||
|
||||
def test_limit_precedence():
|
||||
g = int(settings.version_history_limit)
|
||||
# project.config wins over tenant + global
|
||||
assert _limit_for({"version_history_limit": 7}, {"version_history_limit": 3}) == 3
|
||||
# tenant override used when the project sets none
|
||||
assert _limit_for({"version_history_limit": 7}, {}) == 7
|
||||
assert _limit_for({"version_history_limit": 7}, None) == 7
|
||||
# global default when neither is set
|
||||
assert _limit_for(None, None) == g
|
||||
# a non-integer value falls through to the next source
|
||||
assert _limit_for(None, {"version_history_limit": "nope"}) == g
|
||||
|
||||
|
||||
async def test_project_config_limit_prunes_snapshots():
|
||||
t = f"t_{uuid.uuid4().hex[:8]}"
|
||||
async with SessionLocal() as s:
|
||||
proj = Project(tenant_id=t, name="P", slug=f"p{uuid.uuid4().hex[:6]}", config={"version_history_limit": 2})
|
||||
s.add(proj)
|
||||
await s.flush()
|
||||
wf = Workflow(tenant_id=t, project_id=proj.id, name="w", executable={}, status="draft")
|
||||
s.add(wf)
|
||||
await s.commit()
|
||||
pid, wid = proj.id, wf.id
|
||||
|
||||
async with SessionLocal() as s:
|
||||
for i in range(5):
|
||||
await VersionService.snapshot(
|
||||
s, tenant_id=t, entity_type="workflow", entity_id=wid,
|
||||
data={"name": f"v{i}"}, project_id=pid,
|
||||
)
|
||||
await s.commit()
|
||||
|
||||
async with SessionLocal() as s:
|
||||
rows = await VersionService.list(s, t, "workflow", wid)
|
||||
# project.config limit=2 -> only the two newest snapshots survive
|
||||
assert [r.version_no for r in rows] == [5, 4], [r.version_no for r in rows]
|
||||
Reference in New Issue
Block a user