mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
Refactor owner scope across channels and fix default routing fallback (#1151)
* refactor: add explicit owner scope across channels * fix: tighten routine owner target routing * fix: address owner scope review feedback * Fix owner-scope onboarding and event trigger isolation * Tighten routing fallback and wizard owner validation * fix: address owner-scope follow-up review * fix: tighten owner-scope follow-up details * fix: import Channel trait in telegram test * fix: normalize http webhook sender ids * fix: address remaining owner-scope review issues * fix: reconcile config rebase fallout * fix: reconcile extension manager rebase drift * fix: address current copilot review regressions * fix: restore clippy matrix after rebase
This commit is contained in:
+61
-4
@@ -15,7 +15,13 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from helpers import AUTH_TOKEN, wait_for_port_line, wait_for_ready
|
||||
from helpers import (
|
||||
AUTH_TOKEN,
|
||||
HTTP_WEBHOOK_SECRET,
|
||||
OWNER_SCOPE_ID,
|
||||
wait_for_port_line,
|
||||
wait_for_ready,
|
||||
)
|
||||
|
||||
# Project root (two levels up from tests/e2e/)
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
@@ -92,6 +98,21 @@ def _find_free_port() -> int:
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _reserve_loopback_sockets(count: int) -> list[socket.socket]:
|
||||
"""Bind loopback sockets and keep them open until the server starts."""
|
||||
sockets: list[socket.socket] = []
|
||||
try:
|
||||
while len(sockets) < count:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sockets.append(sock)
|
||||
return sockets
|
||||
except Exception:
|
||||
for sock in sockets:
|
||||
sock.close()
|
||||
raise
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ironclaw_binary():
|
||||
"""Ensure ironclaw binary is built. Returns the binary path."""
|
||||
@@ -108,6 +129,21 @@ def ironclaw_binary():
|
||||
return str(binary)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def server_ports():
|
||||
"""Reserve dynamic ports for the gateway and HTTP webhook channel."""
|
||||
reserved = _reserve_loopback_sockets(2)
|
||||
try:
|
||||
yield {
|
||||
"gateway": reserved[0].getsockname()[1],
|
||||
"http": reserved[1].getsockname()[1],
|
||||
"sockets": reserved,
|
||||
}
|
||||
finally:
|
||||
for sock in reserved:
|
||||
sock.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def mock_llm_server():
|
||||
"""Start the mock LLM server. Yields the base URL."""
|
||||
@@ -177,10 +213,19 @@ def _wasm_build_symlinks():
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir):
|
||||
async def ironclaw_server(
|
||||
ironclaw_binary,
|
||||
mock_llm_server,
|
||||
wasm_tools_dir,
|
||||
server_ports,
|
||||
):
|
||||
"""Start the ironclaw gateway. Yields the base URL."""
|
||||
gateway_port = _find_free_port()
|
||||
home_dir = _HOME_TMPDIR.name
|
||||
gateway_port = server_ports["gateway"]
|
||||
http_port = server_ports["http"]
|
||||
for sock in server_ports["sockets"]:
|
||||
if sock.fileno() != -1:
|
||||
sock.close()
|
||||
env = {
|
||||
# Minimal env: PATH for process spawning, HOME for Rust/cargo defaults
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
@@ -188,11 +233,15 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir):
|
||||
"IRONCLAW_BASE_DIR": os.path.join(home_dir, ".ironclaw"),
|
||||
"RUST_LOG": "ironclaw=info",
|
||||
"RUST_BACKTRACE": "1",
|
||||
"IRONCLAW_OWNER_ID": OWNER_SCOPE_ID,
|
||||
"GATEWAY_ENABLED": "true",
|
||||
"GATEWAY_HOST": "127.0.0.1",
|
||||
"GATEWAY_PORT": str(gateway_port),
|
||||
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
|
||||
"GATEWAY_USER_ID": "e2e-tester",
|
||||
"GATEWAY_USER_ID": "e2e-web-sender",
|
||||
"HTTP_HOST": "127.0.0.1",
|
||||
"HTTP_PORT": str(http_port),
|
||||
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
|
||||
"CLI_ENABLED": "false",
|
||||
"LLM_BACKEND": "openai_compatible",
|
||||
"LLM_BASE_URL": mock_llm_server,
|
||||
@@ -261,6 +310,14 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir):
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def http_channel_server(ironclaw_server, server_ports):
|
||||
"""HTTP webhook channel base URL."""
|
||||
base_url = f"http://127.0.0.1:{server_ports['http']}"
|
||||
await wait_for_ready(f"{base_url}/health", timeout=30)
|
||||
return base_url
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def ironclaw_server_with_webhook_secret(ironclaw_binary, mock_llm_server, wasm_tools_dir):
|
||||
"""Start ironclaw with HTTP_WEBHOOK_SECRET configured for webhook tests.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Shared helpers for E2E tests."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import time
|
||||
|
||||
@@ -95,12 +97,21 @@ SEL = {
|
||||
"toast_success": ".toast.toast-success",
|
||||
"toast_error": ".toast.toast-error",
|
||||
"toast_info": ".toast.toast-info",
|
||||
# Jobs / routines
|
||||
"jobs_tbody": "#jobs-tbody",
|
||||
"job_row": "#jobs-tbody .job-row",
|
||||
"jobs_empty": "#jobs-empty",
|
||||
"routines_tbody": "#routines-tbody",
|
||||
"routine_row": "#routines-tbody .routine-row",
|
||||
"routines_empty": "#routines-empty",
|
||||
}
|
||||
|
||||
TABS = ["chat", "memory", "jobs", "routines", "extensions", "skills"]
|
||||
|
||||
# Auth token used across all tests
|
||||
AUTH_TOKEN = "e2e-test-token"
|
||||
OWNER_SCOPE_ID = "e2e-owner-scope"
|
||||
HTTP_WEBHOOK_SECRET = "e2e-http-webhook-secret"
|
||||
|
||||
|
||||
async def wait_for_ready(url: str, *, timeout: float = 60, interval: float = 0.5):
|
||||
@@ -162,3 +173,16 @@ async def api_post(base_url: str, path: str, **kwargs) -> httpx.Response:
|
||||
timeout=kwargs.pop("timeout", 10),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def signed_http_webhook_headers(body: bytes) -> dict[str, str]:
|
||||
"""Return headers for the owner-scoped HTTP webhook channel."""
|
||||
digest = hmac.new(
|
||||
HTTP_WEBHOOK_SECRET.encode("utf-8"),
|
||||
body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": f"sha256={digest}",
|
||||
}
|
||||
|
||||
@@ -26,6 +26,40 @@ DEFAULT_RESPONSE = "I understand your request."
|
||||
TOOL_CALL_PATTERNS = [
|
||||
(re.compile(r"echo (.+)", re.IGNORECASE), "echo", lambda m: {"message": m.group(1)}),
|
||||
(re.compile(r"what time|current time", re.IGNORECASE), "time", lambda _: {"operation": "now"}),
|
||||
(
|
||||
re.compile(
|
||||
r"create lightweight owner routine (?P<name>[a-z0-9][a-z0-9_-]*)",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"routine_create",
|
||||
lambda m: {
|
||||
"name": m.group("name"),
|
||||
"description": f"Owner-scope routine {m.group('name')}",
|
||||
"trigger_type": "manual",
|
||||
"prompt": f"Confirm that {m.group('name')} executed.",
|
||||
"action_type": "lightweight",
|
||||
"use_tools": False,
|
||||
},
|
||||
),
|
||||
(
|
||||
re.compile(
|
||||
r"create full[- ]job owner routine (?P<name>[a-z0-9][a-z0-9_-]*)",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"routine_create",
|
||||
lambda m: {
|
||||
"name": m.group("name"),
|
||||
"description": f"Owner-scope full-job routine {m.group('name')}",
|
||||
"trigger_type": "manual",
|
||||
"prompt": f"Complete the routine job for {m.group('name')}.",
|
||||
"action_type": "full_job",
|
||||
},
|
||||
),
|
||||
(
|
||||
re.compile(r"list owner routines", re.IGNORECASE),
|
||||
"routine_list",
|
||||
lambda _: {},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Owner-scope end-to-end scenarios.
|
||||
|
||||
These tests exercise the explicit owner model across:
|
||||
- the web gateway chat UI
|
||||
- the owner-scoped HTTP webhook channel
|
||||
- routine tools / routines tab
|
||||
- job creation via routine execution / jobs tab
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from helpers import SEL, AUTH_TOKEN, signed_http_webhook_headers
|
||||
|
||||
|
||||
async def _send_and_get_response(
|
||||
page,
|
||||
message: str,
|
||||
*,
|
||||
expected_fragment: str,
|
||||
timeout: int = 30000,
|
||||
) -> str:
|
||||
"""Send a chat message and return the newest assistant response text."""
|
||||
chat_input = page.locator(SEL["chat_input"])
|
||||
await chat_input.wait_for(state="visible", timeout=5000)
|
||||
|
||||
assistant_sel = SEL["message_assistant"]
|
||||
before_count = await page.locator(assistant_sel).count()
|
||||
|
||||
await chat_input.fill(message)
|
||||
await chat_input.press("Enter")
|
||||
|
||||
expected = before_count + 1
|
||||
await page.wait_for_function(
|
||||
"""({ assistantSelector, expectedCount, expectedFragment }) => {
|
||||
const messages = document.querySelectorAll(assistantSelector);
|
||||
if (messages.length < expectedCount) return false;
|
||||
const text = (messages[messages.length - 1].innerText || '').trim().toLowerCase();
|
||||
return text.includes(expectedFragment.toLowerCase());
|
||||
}""",
|
||||
arg={
|
||||
"assistantSelector": assistant_sel,
|
||||
"expectedCount": expected,
|
||||
"expectedFragment": expected_fragment,
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return await page.locator(assistant_sel).last.inner_text()
|
||||
|
||||
|
||||
async def _post_http_webhook(
|
||||
http_channel_server: str,
|
||||
*,
|
||||
content: str,
|
||||
sender_id: str,
|
||||
thread_id: str,
|
||||
) -> str:
|
||||
"""Send a signed request to the owner-scoped HTTP webhook channel."""
|
||||
payload = {
|
||||
"user_id": sender_id,
|
||||
"thread_id": thread_id,
|
||||
"content": content,
|
||||
"wait_for_response": True,
|
||||
}
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{http_channel_server}/webhook",
|
||||
content=body,
|
||||
headers=signed_http_webhook_headers(body),
|
||||
timeout=90,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, (
|
||||
f"HTTP webhook failed: {response.status_code} {response.text[:400]}"
|
||||
)
|
||||
data = response.json()
|
||||
assert data["status"] == "accepted", f"Unexpected webhook response: {data}"
|
||||
assert data["response"], f"Expected synchronous response body, got: {data}"
|
||||
return data["response"]
|
||||
|
||||
|
||||
async def _open_tab(page, tab: str) -> None:
|
||||
btn = page.locator(SEL["tab_button"].format(tab=tab))
|
||||
await btn.click()
|
||||
await page.locator(SEL["tab_panel"].format(tab=tab)).wait_for(
|
||||
state="visible",
|
||||
timeout=5000,
|
||||
)
|
||||
|
||||
|
||||
async def _wait_for_routine(base_url: str, name: str, timeout: float = 20.0) -> dict:
|
||||
"""Poll the routines API until the named routine exists."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
for _ in range(int(timeout * 2)):
|
||||
response = await client.get(
|
||||
f"{base_url}/api/routines",
|
||||
headers={"Authorization": f"Bearer {AUTH_TOKEN}"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
routines = response.json()["routines"]
|
||||
for routine in routines:
|
||||
if routine["name"] == name:
|
||||
return routine
|
||||
await _poll_sleep()
|
||||
raise AssertionError(f"Routine '{name}' was not created within {timeout}s")
|
||||
|
||||
|
||||
async def _wait_for_job(base_url: str, title: str, timeout: float = 30.0) -> dict:
|
||||
"""Poll the jobs API until the named job exists."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
for _ in range(int(timeout * 2)):
|
||||
response = await client.get(
|
||||
f"{base_url}/api/jobs",
|
||||
headers={"Authorization": f"Bearer {AUTH_TOKEN}"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
jobs = response.json()["jobs"]
|
||||
for job in jobs:
|
||||
if job["title"] == title:
|
||||
return job
|
||||
await _poll_sleep()
|
||||
raise AssertionError(f"Job '{title}' was not created within {timeout}s")
|
||||
|
||||
|
||||
async def _poll_sleep() -> None:
|
||||
"""Small shared backoff for API polling loops."""
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
async def test_http_channel_created_routine_is_visible_in_web_routines_tab(
|
||||
page,
|
||||
ironclaw_server,
|
||||
http_channel_server,
|
||||
):
|
||||
"""A routine created from the HTTP channel is visible in the web owner UI."""
|
||||
routine_name = f"owner-http-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
response_text = await _post_http_webhook(
|
||||
http_channel_server,
|
||||
content=f"create lightweight owner routine {routine_name}",
|
||||
sender_id="external-sender-alpha",
|
||||
thread_id="http-owner-routine-thread",
|
||||
)
|
||||
assert routine_name in response_text
|
||||
|
||||
await _wait_for_routine(ironclaw_server, routine_name)
|
||||
|
||||
await _open_tab(page, "routines")
|
||||
await page.locator(SEL["routine_row"]).filter(has_text=routine_name).first.wait_for(
|
||||
state="visible",
|
||||
timeout=15000,
|
||||
)
|
||||
|
||||
|
||||
async def test_web_created_routine_is_listed_from_http_channel_across_senders(
|
||||
page,
|
||||
ironclaw_server,
|
||||
http_channel_server,
|
||||
):
|
||||
"""Routines created in web chat remain owner-global across HTTP senders/threads."""
|
||||
routine_name = f"owner-web-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
assistant_text = await _send_and_get_response(
|
||||
page,
|
||||
f"create lightweight owner routine {routine_name}",
|
||||
expected_fragment=routine_name,
|
||||
)
|
||||
assert routine_name in assistant_text
|
||||
|
||||
await _wait_for_routine(ironclaw_server, routine_name)
|
||||
|
||||
first_sender_text = await _post_http_webhook(
|
||||
http_channel_server,
|
||||
content="list owner routines",
|
||||
sender_id="http-sender-one",
|
||||
thread_id="owner-list-thread-a",
|
||||
)
|
||||
second_sender_text = await _post_http_webhook(
|
||||
http_channel_server,
|
||||
content="list owner routines",
|
||||
sender_id="http-sender-two",
|
||||
thread_id="owner-list-thread-b",
|
||||
)
|
||||
|
||||
assert routine_name in first_sender_text, first_sender_text
|
||||
assert routine_name in second_sender_text, second_sender_text
|
||||
|
||||
|
||||
async def test_http_created_full_job_routine_can_be_run_from_web_and_shows_in_jobs(
|
||||
page,
|
||||
ironclaw_server,
|
||||
http_channel_server,
|
||||
):
|
||||
"""A full-job routine created via HTTP can be run from the web UI and create a job."""
|
||||
routine_name = f"owner-job-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
response_text = await _post_http_webhook(
|
||||
http_channel_server,
|
||||
content=f"create full-job owner routine {routine_name}",
|
||||
sender_id="http-job-sender",
|
||||
thread_id="owner-job-thread",
|
||||
)
|
||||
assert routine_name in response_text
|
||||
|
||||
await _wait_for_routine(ironclaw_server, routine_name)
|
||||
|
||||
await _open_tab(page, "routines")
|
||||
routine_row = page.locator(SEL["routine_row"]).filter(has_text=routine_name).first
|
||||
await routine_row.wait_for(state="visible", timeout=15000)
|
||||
await routine_row.locator('button[data-action="trigger-routine"]').click()
|
||||
|
||||
await _wait_for_job(ironclaw_server, routine_name, timeout=45.0)
|
||||
|
||||
await _open_tab(page, "jobs")
|
||||
await page.locator(SEL["job_row"]).filter(has_text=routine_name).first.wait_for(
|
||||
state="visible",
|
||||
timeout=20000,
|
||||
)
|
||||
@@ -155,7 +155,7 @@ mod tests {
|
||||
}
|
||||
|
||||
assert_eq!(routine.notify.channel.as_deref(), Some("telegram"));
|
||||
assert_eq!(routine.notify.user, "ops-team");
|
||||
assert_eq!(routine.notify.user.as_deref(), Some("ops-team"));
|
||||
assert_eq!(routine.guardrails.cooldown.as_secs(), 600);
|
||||
|
||||
rig.shutdown();
|
||||
|
||||
@@ -48,6 +48,19 @@ mod tests {
|
||||
Arc::new(Workspace::new_with_db("default", db.clone()))
|
||||
}
|
||||
|
||||
fn make_message(
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
owner_id: &str,
|
||||
sender_id: &str,
|
||||
content: &str,
|
||||
) -> IncomingMessage {
|
||||
IncomingMessage::new(channel, user_id, content)
|
||||
.with_owner_id(owner_id)
|
||||
.with_sender_id(sender_id)
|
||||
.with_metadata(serde_json::json!({}))
|
||||
}
|
||||
|
||||
/// Helper to insert a routine directly into the database.
|
||||
fn make_routine(name: &str, trigger: Trigger, prompt: &str) -> Routine {
|
||||
Routine {
|
||||
@@ -218,7 +231,13 @@ mod tests {
|
||||
engine.refresh_event_cache().await;
|
||||
|
||||
// Positive match: message containing "deploy to production".
|
||||
let matching_msg = IncomingMessage::new("test", "default", "deploy to production now");
|
||||
let matching_msg = make_message(
|
||||
"test",
|
||||
"default",
|
||||
"default",
|
||||
"default",
|
||||
"deploy to production now",
|
||||
);
|
||||
let fired = engine.check_event_triggers(&matching_msg).await;
|
||||
assert!(
|
||||
fired >= 1,
|
||||
@@ -229,12 +248,114 @@ mod tests {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Negative match: message that doesn't match.
|
||||
let non_matching_msg =
|
||||
IncomingMessage::new("test", "default", "check the staging environment");
|
||||
let non_matching_msg = make_message(
|
||||
"test",
|
||||
"default",
|
||||
"default",
|
||||
"default",
|
||||
"check the staging environment",
|
||||
);
|
||||
let fired_neg = engine.check_event_triggers(&non_matching_msg).await;
|
||||
assert_eq!(fired_neg, 0, "Expected 0 routines fired on non-match");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn event_trigger_respects_message_user_scope() {
|
||||
let (db, _tmp) = create_test_db().await;
|
||||
let ws = create_workspace(&db);
|
||||
|
||||
let trace = LlmTrace::single_turn(
|
||||
"test-event-user-scope",
|
||||
"deploy",
|
||||
vec![TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::Text {
|
||||
content: "Owner event handled".to_string(),
|
||||
input_tokens: 50,
|
||||
output_tokens: 8,
|
||||
},
|
||||
expected_tool_results: vec![],
|
||||
}],
|
||||
);
|
||||
let llm = Arc::new(TraceLlm::from_trace(trace));
|
||||
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16);
|
||||
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
|
||||
max_output_length: 100_000,
|
||||
injection_check_enabled: true,
|
||||
}));
|
||||
|
||||
let engine = Arc::new(RoutineEngine::new(
|
||||
RoutineConfig::default(),
|
||||
db.clone(),
|
||||
llm,
|
||||
ws,
|
||||
notify_tx,
|
||||
None,
|
||||
tools,
|
||||
safety,
|
||||
));
|
||||
|
||||
let routine = make_routine(
|
||||
"owner-deploy-watcher",
|
||||
Trigger::Event {
|
||||
channel: None,
|
||||
pattern: "deploy.*production".to_string(),
|
||||
},
|
||||
"Report on deployment.",
|
||||
);
|
||||
db.create_routine(&routine).await.expect("create_routine");
|
||||
engine.refresh_event_cache().await;
|
||||
|
||||
let guest_msg = make_message(
|
||||
"telegram",
|
||||
"guest",
|
||||
"default",
|
||||
"guest-sender",
|
||||
"deploy to production now",
|
||||
);
|
||||
let guest_fired = engine.check_event_triggers(&guest_msg).await;
|
||||
assert_eq!(
|
||||
guest_fired, 0,
|
||||
"Guest scope must not fire owner event routines"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
let guest_runs = db
|
||||
.list_routine_runs(routine.id, 10)
|
||||
.await
|
||||
.expect("list_routine_runs after guest message");
|
||||
assert!(
|
||||
guest_runs.is_empty(),
|
||||
"Guest message should not create routine runs"
|
||||
);
|
||||
|
||||
let owner_msg = make_message(
|
||||
"telegram",
|
||||
"default",
|
||||
"default",
|
||||
"owner-sender",
|
||||
"deploy to production now",
|
||||
);
|
||||
let owner_fired = engine.check_event_triggers(&owner_msg).await;
|
||||
assert!(
|
||||
owner_fired >= 1,
|
||||
"Owner scope should fire matching owner event routine"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
let owner_runs = db
|
||||
.list_routine_runs(routine.id, 10)
|
||||
.await
|
||||
.expect("list_routine_runs after owner message");
|
||||
assert_eq!(
|
||||
owner_runs.len(),
|
||||
1,
|
||||
"Owner message should create exactly one run"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 3: system_event_trigger_matches_and_filters
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -434,7 +555,13 @@ mod tests {
|
||||
engine.refresh_event_cache().await;
|
||||
|
||||
// First fire should work.
|
||||
let msg = IncomingMessage::new("test", "default", "test-cooldown trigger");
|
||||
let msg = make_message(
|
||||
"test",
|
||||
"default",
|
||||
"default",
|
||||
"default",
|
||||
"test-cooldown trigger",
|
||||
);
|
||||
let fired1 = engine.check_event_triggers(&msg).await;
|
||||
assert!(fired1 >= 1, "First fire should work");
|
||||
|
||||
|
||||
@@ -239,6 +239,7 @@ impl GatewayWorkflowHarness {
|
||||
let mut agent = Agent::new(
|
||||
components.config.agent.clone(),
|
||||
AgentDeps {
|
||||
owner_id: components.config.owner_id.clone(),
|
||||
store: components.db,
|
||||
llm: components.llm,
|
||||
cheap_llm: components.cheap_llm,
|
||||
|
||||
@@ -612,6 +612,7 @@ impl TestRigBuilder {
|
||||
|
||||
// 7. Construct AgentDeps from AppComponents (mirrors main.rs).
|
||||
let deps = AgentDeps {
|
||||
owner_id: components.config.owner_id.clone(),
|
||||
store: components.db,
|
||||
llm: components.llm,
|
||||
cheap_llm: components.cheap_llm,
|
||||
|
||||
@@ -6,17 +6,21 @@
|
||||
//! 1. When owner_id is null and dm_policy is "allowlist", unauthorized users in
|
||||
//! group chats are dropped even if they @mention the bot
|
||||
//! 2. When owner_id is null and dm_policy is "open", all users can interact
|
||||
//! 3. When owner_id is set, only that user can interact
|
||||
//! 3. When owner_id is set, the owner gets instance-global access while
|
||||
//! non-owner senders remain channel-scoped guests subject to authorization
|
||||
//! 4. Authorization works correctly for both private and group chats
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::StreamExt;
|
||||
use ironclaw::channels::Channel;
|
||||
use ironclaw::channels::wasm::{
|
||||
ChannelCapabilities, PreparedChannelModule, WasmChannel, WasmChannelRuntime,
|
||||
WasmChannelRuntimeConfig,
|
||||
};
|
||||
use ironclaw::pairing::PairingStore;
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
/// Skip the test if the Telegram WASM module hasn't been built.
|
||||
/// In CI (detected via the `CI` env var), panic instead of skipping so a
|
||||
@@ -97,6 +101,14 @@ async fn load_telegram_module(
|
||||
async fn create_telegram_channel(
|
||||
runtime: Arc<WasmChannelRuntime>,
|
||||
config_json: &str,
|
||||
) -> WasmChannel {
|
||||
create_telegram_channel_with_store(runtime, config_json, Arc::new(PairingStore::new())).await
|
||||
}
|
||||
|
||||
async fn create_telegram_channel_with_store(
|
||||
runtime: Arc<WasmChannelRuntime>,
|
||||
config_json: &str,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
) -> WasmChannel {
|
||||
let module = load_telegram_module(&runtime)
|
||||
.await
|
||||
@@ -106,8 +118,9 @@ async fn create_telegram_channel(
|
||||
runtime,
|
||||
module,
|
||||
ChannelCapabilities::for_channel("telegram").with_path("/webhook/telegram"),
|
||||
"default",
|
||||
config_json.to_string(),
|
||||
Arc::new(PairingStore::new()),
|
||||
pairing_store,
|
||||
None,
|
||||
)
|
||||
}
|
||||
@@ -245,31 +258,29 @@ async fn test_group_message_authorized_user_allowed() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_group_message_with_owner_id_set() {
|
||||
async fn test_private_message_with_owner_id_set_uses_guest_pairing_flow() {
|
||||
require_telegram_wasm!();
|
||||
let runtime = create_test_runtime();
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let pairing_store = Arc::new(PairingStore::with_base_dir(dir.path().to_path_buf()));
|
||||
|
||||
// Config: owner_id=123 (only this user can interact)
|
||||
// Config: owner_id=123, non-owner private DMs should enter the guest
|
||||
// pairing flow instead of being rejected solely for not being the owner.
|
||||
let config = serde_json::json!({
|
||||
"bot_username": "test_bot",
|
||||
"bot_username": null,
|
||||
"owner_id": 123,
|
||||
"dm_policy": "allowlist",
|
||||
"allow_from": ["anyone"], // ignored when owner_id is set
|
||||
"dm_policy": "pairing",
|
||||
"allow_from": [],
|
||||
"respond_to_all_group_messages": false
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let channel = create_telegram_channel(runtime, &config).await;
|
||||
let channel = create_telegram_channel_with_store(runtime, &config, pairing_store.clone()).await;
|
||||
|
||||
// Message from different user (should be dropped)
|
||||
// Non-owner private message should produce a pairing request.
|
||||
let update = build_telegram_update(
|
||||
3,
|
||||
102,
|
||||
-123456789,
|
||||
"group",
|
||||
999, // Not the owner
|
||||
"Other",
|
||||
"Hey @test_bot hello",
|
||||
3, 102, 999, "private", 999, // Not the owner
|
||||
"Other", "hello",
|
||||
);
|
||||
|
||||
let response = channel
|
||||
@@ -286,8 +297,64 @@ async fn test_group_message_with_owner_id_set() {
|
||||
|
||||
assert_eq!(response.status, 200);
|
||||
|
||||
// REGRESSION TEST: Non-owner messages are dropped when owner_id is set
|
||||
// This behavior is consistent and not affected by the fix
|
||||
let pending = pairing_store
|
||||
.list_pending("telegram")
|
||||
.expect("pairing store should be readable");
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].id, "999");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_private_messages_use_chat_id_as_thread_scope() {
|
||||
require_telegram_wasm!();
|
||||
let runtime = create_test_runtime();
|
||||
|
||||
let config = serde_json::json!({
|
||||
"bot_username": null,
|
||||
"owner_id": null,
|
||||
"dm_policy": "open",
|
||||
"allow_from": [],
|
||||
"respond_to_all_group_messages": false
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let channel = create_telegram_channel(runtime, &config).await;
|
||||
let mut stream = channel.start().await.expect("Failed to start channel");
|
||||
|
||||
for (update_id, message_id, text) in [(6, 105, "first"), (7, 106, "second")] {
|
||||
let update = build_telegram_update(
|
||||
update_id,
|
||||
message_id,
|
||||
999,
|
||||
"private",
|
||||
999,
|
||||
"ThreadUser",
|
||||
text,
|
||||
);
|
||||
|
||||
let response = channel
|
||||
.call_on_http_request(
|
||||
"POST",
|
||||
"/webhook/telegram",
|
||||
&HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&update,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("HTTP callback failed");
|
||||
|
||||
assert_eq!(response.status, 200);
|
||||
|
||||
let msg = timeout(Duration::from_secs(1), stream.next())
|
||||
.await
|
||||
.expect("message should arrive")
|
||||
.expect("stream should yield a message");
|
||||
assert_eq!(msg.thread_id.as_deref(), Some("999"));
|
||||
assert_eq!(msg.conversation_scope(), Some("999"));
|
||||
}
|
||||
|
||||
channel.shutdown().await.expect("Shutdown failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -43,6 +43,7 @@ fn create_test_channel(
|
||||
runtime,
|
||||
prepared,
|
||||
capabilities,
|
||||
"default",
|
||||
"{}".to_string(),
|
||||
Arc::new(PairingStore::new()),
|
||||
None,
|
||||
|
||||
Reference in New Issue
Block a user