26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
225 lines
7.5 KiB
Rust
225 lines
7.5 KiB
Rust
//! Integration tests against a real (in-process) HTTP server bound to an
|
|
//! ephemeral localhost port. No mocking: the client speaks genuine HTTP over a
|
|
//! TCP socket and we assert both the parsed responses and the bytes the server
|
|
//! actually received (auth + workspace header forwarding).
|
|
|
|
use std::io::{BufRead, BufReader, Read, Write};
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::thread;
|
|
|
|
use lean_ctx_client::{CallContext, EventQuery, LeanCtxClient, LeanCtxError};
|
|
use serde_json::json;
|
|
|
|
/// One observed request (for server-side assertions).
|
|
struct ReqLog {
|
|
method: String,
|
|
path: String,
|
|
authorization: Option<String>,
|
|
workspace: Option<String>,
|
|
body: String,
|
|
}
|
|
|
|
/// Serve exactly `count` connections (one request each), then return the log.
|
|
fn start_server(count: usize) -> (String, thread::JoinHandle<Vec<ReqLog>>) {
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
|
|
let addr = listener.local_addr().expect("addr");
|
|
let base = format!("http://{addr}");
|
|
let handle = thread::spawn(move || {
|
|
let mut log = Vec::new();
|
|
for _ in 0..count {
|
|
let (stream, _) = listener.accept().expect("accept");
|
|
log.push(handle_conn(stream));
|
|
}
|
|
log
|
|
});
|
|
(base, handle)
|
|
}
|
|
|
|
fn handle_conn(mut stream: TcpStream) -> ReqLog {
|
|
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
|
|
|
|
let mut request_line = String::new();
|
|
reader.read_line(&mut request_line).expect("request line");
|
|
let mut parts = request_line.split_whitespace();
|
|
let method = parts.next().unwrap_or_default().to_string();
|
|
let path = parts.next().unwrap_or_default().to_string();
|
|
|
|
let mut content_length = 0usize;
|
|
let mut authorization = None;
|
|
let mut workspace = None;
|
|
loop {
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).expect("header");
|
|
let trimmed = line.trim_end();
|
|
if trimmed.is_empty() {
|
|
break;
|
|
}
|
|
let (key, value) = trimmed.split_once(':').unwrap_or((trimmed, ""));
|
|
let value = value.trim().to_string();
|
|
match key.to_ascii_lowercase().as_str() {
|
|
"content-length" => content_length = value.parse().unwrap_or(0),
|
|
"authorization" => authorization = Some(value),
|
|
"x-leanctx-workspace" => workspace = Some(value),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let mut body = vec![0u8; content_length];
|
|
if content_length > 0 {
|
|
reader.read_exact(&mut body).expect("body");
|
|
}
|
|
let body = String::from_utf8_lossy(&body).to_string();
|
|
|
|
let response = route(&method, &path, &authorization, &body);
|
|
stream.write_all(response.as_bytes()).expect("write");
|
|
stream.flush().ok();
|
|
|
|
ReqLog {
|
|
method,
|
|
path,
|
|
authorization,
|
|
workspace,
|
|
body,
|
|
}
|
|
}
|
|
|
|
fn http(status: &str, content_type: &str, body: &str) -> String {
|
|
format!(
|
|
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
|
body.len()
|
|
)
|
|
}
|
|
|
|
fn route(method: &str, path: &str, authorization: &Option<String>, _body: &str) -> String {
|
|
let route_path = path.split('?').next().unwrap_or(path);
|
|
match (method, route_path) {
|
|
("GET", "/health") => http("200 OK", "text/plain", "ok"),
|
|
("GET", "/v1/capabilities") => http(
|
|
"200 OK",
|
|
"application/json",
|
|
&json!({ "contract_version": 1, "plane": "personal" }).to_string(),
|
|
),
|
|
("GET", "/v1/tools") => http(
|
|
"200 OK",
|
|
"application/json",
|
|
&json!({ "tools": [{ "name": "ctx_search" }], "total": 1, "offset": 0, "limit": 2 })
|
|
.to_string(),
|
|
),
|
|
("POST", "/v1/tools/call") => {
|
|
let auth = authorization.clone().unwrap_or_default();
|
|
http(
|
|
"200 OK",
|
|
"application/json",
|
|
&json!({ "result": { "content": [{ "type": "text", "text": format!("pong:{auth}") }] } })
|
|
.to_string(),
|
|
)
|
|
}
|
|
("GET", "/v1/events") => {
|
|
let frames = "data: {\"id\":1,\"workspaceId\":\"w\",\"channelId\":\"c\",\"kind\":\"tool_call\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"consistencyLevel\":\"local\",\"payload\":{}}\n\n: ping\n\ndata: {\"id\":2,\"workspaceId\":\"w\",\"channelId\":\"c\",\"kind\":\"session_update\",\"timestamp\":\"2026-01-01T00:00:01Z\",\"consistencyLevel\":\"eventual\",\"payload\":{}}\n\n";
|
|
http("200 OK", "text/event-stream", frames)
|
|
}
|
|
_ => http(
|
|
"401 Unauthorized",
|
|
"application/json",
|
|
&json!({ "error": "invalid bearer token", "error_code": "unauthorized" }).to_string(),
|
|
),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn health_capabilities_and_tools() {
|
|
let (base, server) = start_server(3);
|
|
let client = LeanCtxClient::new(&base).unwrap();
|
|
|
|
assert_eq!(client.health().unwrap(), "ok");
|
|
|
|
let caps = client.capabilities().unwrap();
|
|
assert_eq!(caps["contract_version"], 1);
|
|
assert_eq!(caps["plane"], "personal");
|
|
|
|
let tools = client.list_tools(Some(0), Some(2)).unwrap();
|
|
assert_eq!(tools.total, 1);
|
|
assert_eq!(tools.limit, 2);
|
|
assert_eq!(tools.tools.len(), 1);
|
|
|
|
let log = server.join().unwrap();
|
|
assert_eq!(log[2].method, "GET");
|
|
assert!(log[2].path.contains("offset=0"));
|
|
assert!(log[2].path.contains("limit=2"));
|
|
}
|
|
|
|
#[test]
|
|
fn call_tool_forwards_auth_and_workspace() {
|
|
let (base, server) = start_server(1);
|
|
let client = LeanCtxClient::builder(&base)
|
|
.bearer_token("secret-token")
|
|
.workspace_id("acme")
|
|
.build()
|
|
.unwrap();
|
|
|
|
let text = client
|
|
.call_tool_text(
|
|
"ctx_search",
|
|
Some(json!({ "pattern": "x" })),
|
|
None::<&CallContext>,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(text, "pong:Bearer secret-token");
|
|
|
|
let log = server.join().unwrap();
|
|
assert_eq!(log[0].method, "POST");
|
|
assert_eq!(log[0].authorization.as_deref(), Some("Bearer secret-token"));
|
|
assert_eq!(log[0].workspace.as_deref(), Some("acme"));
|
|
assert!(log[0].body.contains("\"workspaceId\":\"acme\""));
|
|
assert!(log[0].body.contains("\"name\":\"ctx_search\""));
|
|
}
|
|
|
|
#[test]
|
|
fn non_object_arguments_are_rejected_locally() {
|
|
let client = LeanCtxClient::new("http://127.0.0.1:9").unwrap();
|
|
let err = client
|
|
.call_tool("t", Some(json!([1, 2, 3])), None::<&CallContext>)
|
|
.unwrap_err();
|
|
assert!(matches!(err, LeanCtxError::Config(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn http_error_envelope_is_parsed() {
|
|
let (base, server) = start_server(1);
|
|
let client = LeanCtxClient::new(&base).unwrap();
|
|
|
|
let err = client.manifest().unwrap_err();
|
|
match err {
|
|
LeanCtxError::Http(e) => {
|
|
assert_eq!(e.status, 401);
|
|
assert_eq!(e.error_code.as_deref(), Some("unauthorized"));
|
|
assert_eq!(e.message, "invalid bearer token");
|
|
}
|
|
other => panic!("expected Http error, got {other:?}"),
|
|
}
|
|
|
|
server.join().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn subscribe_events_streams_and_skips_heartbeats() {
|
|
let (base, server) = start_server(1);
|
|
let client = LeanCtxClient::new(&base).unwrap();
|
|
|
|
let events: Vec<_> = client
|
|
.subscribe_events(&EventQuery {
|
|
workspace_id: Some("w".into()),
|
|
channel_id: Some("c".into()),
|
|
..Default::default()
|
|
})
|
|
.unwrap()
|
|
.map(|r| r.unwrap())
|
|
.collect();
|
|
|
|
assert_eq!(events.len(), 2);
|
|
assert_eq!(events[0].id, 1);
|
|
assert_eq!(events[1].kind, "session_update");
|
|
|
|
server.join().unwrap();
|
|
}
|