mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
559 lines
20 KiB
Rust
559 lines
20 KiB
Rust
use axum::body::{Body, to_bytes};
|
|
use axum::http::{Request, StatusCode};
|
|
use fabro_static::EnvVars;
|
|
use tower::ServiceExt;
|
|
|
|
use crate::helpers::{
|
|
MINIMAL_DOT, api, minimal_manifest_json, response_json, response_status, test_app_state,
|
|
};
|
|
|
|
async fn create_run(app: &axum::Router) -> String {
|
|
let request = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_string(&minimal_manifest_json(MINIMAL_DOT))
|
|
.expect("manifest should serialize"),
|
|
))
|
|
.expect("create-run request should build");
|
|
let body = response_json(
|
|
app.clone().oneshot(request).await.unwrap(),
|
|
StatusCode::CREATED,
|
|
"POST /api/v1/runs",
|
|
)
|
|
.await;
|
|
body["id"]
|
|
.as_str()
|
|
.expect("create-run response should include an id")
|
|
.to_string()
|
|
}
|
|
|
|
async fn create_session(app: &axum::Router, run_id: &str, title: &str) -> serde_json::Value {
|
|
create_session_with_body(app, run_id, serde_json::json!({ "title": title })).await
|
|
}
|
|
|
|
async fn create_session_with_model(
|
|
app: &axum::Router,
|
|
run_id: &str,
|
|
title: &str,
|
|
model: &str,
|
|
) -> serde_json::Value {
|
|
create_session_with_body(
|
|
app,
|
|
run_id,
|
|
serde_json::json!({ "title": title, "model": model }),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn create_session_with_body(
|
|
app: &axum::Router,
|
|
run_id: &str,
|
|
body: serde_json::Value,
|
|
) -> serde_json::Value {
|
|
response_json(
|
|
create_session_response(app, run_id, body).await,
|
|
StatusCode::CREATED,
|
|
format!("POST /api/v1/runs/{run_id}/sessions"),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn create_session_response(
|
|
app: &axum::Router,
|
|
run_id: &str,
|
|
body: serde_json::Value,
|
|
) -> axum::response::Response {
|
|
let request = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/sessions")))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_string(&body).expect("session request should serialize"),
|
|
))
|
|
.expect("create-session request should build");
|
|
app.clone().oneshot(request).await.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn run_bound_session_is_created_as_run_event_and_resolves_by_flat_id() {
|
|
let app = fabro_server::test_support::build_test_router(test_app_state());
|
|
let run_id = create_run(&app).await;
|
|
|
|
let created = create_session(&app, &run_id, "Ask Fabro").await;
|
|
let session_id = created["id"]
|
|
.as_str()
|
|
.expect("session response should include an id");
|
|
assert_eq!(created["run_id"], run_id);
|
|
assert_eq!(created["title"], "Ask Fabro");
|
|
assert_session_metadata_only(&created);
|
|
assert!(session_id.parse::<fabro_types::SessionId>().is_ok());
|
|
|
|
let get_request = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/sessions/{session_id}")))
|
|
.body(Body::empty())
|
|
.expect("get-session request should build");
|
|
let fetched = response_json(
|
|
app.clone().oneshot(get_request).await.unwrap(),
|
|
StatusCode::OK,
|
|
format!("GET /api/v1/sessions/{session_id}"),
|
|
)
|
|
.await;
|
|
assert_eq!(fetched["id"], session_id);
|
|
assert_eq!(fetched["run_id"], run_id);
|
|
assert_session_metadata_only(&fetched);
|
|
assert_eq!(fetched["messages"].as_array().unwrap().len(), 0);
|
|
assert!(fetched["active_turn"].is_null());
|
|
|
|
let events_request = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}/events")))
|
|
.body(Body::empty())
|
|
.expect("run-events request should build");
|
|
let events = response_json(
|
|
app.clone().oneshot(events_request).await.unwrap(),
|
|
StatusCode::OK,
|
|
format!("GET /api/v1/runs/{run_id}/events"),
|
|
)
|
|
.await;
|
|
let session_events: Vec<_> = events["data"]
|
|
.as_array()
|
|
.expect("events response should include data")
|
|
.iter()
|
|
.filter(|event| event["session_id"] == session_id)
|
|
.collect();
|
|
assert_eq!(session_events.len(), 1);
|
|
assert_eq!(fetched["last_seq"], session_events[0]["seq"]);
|
|
assert_eq!(session_events[0]["event"], "run.session.created");
|
|
assert!(
|
|
session_events[0]["properties"].get("permissions").is_none(),
|
|
"run session creation event should not expose permissions"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn generic_session_creation_requires_the_dedicated_operation_without_advancing_history() {
|
|
let app = fabro_server::test_support::build_test_router(test_app_state());
|
|
let run_id = create_run(&app).await;
|
|
let before_request = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}/events")))
|
|
.body(Body::empty())
|
|
.expect("run-events request should build");
|
|
let before = response_json(
|
|
app.clone().oneshot(before_request).await.unwrap(),
|
|
StatusCode::OK,
|
|
format!("GET /api/v1/runs/{run_id}/events before rejected append"),
|
|
)
|
|
.await;
|
|
|
|
let session_id = fabro_types::SessionId::new();
|
|
let request = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/events")))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_string(&serde_json::json!({
|
|
"id": ulid::Ulid::new().to_string(),
|
|
"ts": "2026-08-31T12:00:00Z",
|
|
"run_id": run_id,
|
|
"event": "run.session.created",
|
|
"session_id": session_id,
|
|
"properties": { "title": "Injected session" },
|
|
}))
|
|
.expect("session creation event should serialize"),
|
|
))
|
|
.expect("append-event request should build");
|
|
let rejected = response_json(
|
|
app.clone().oneshot(request).await.unwrap(),
|
|
StatusCode::BAD_REQUEST,
|
|
format!("POST /api/v1/runs/{run_id}/events with session creation"),
|
|
)
|
|
.await;
|
|
let detail = rejected["errors"][0]["detail"]
|
|
.as_str()
|
|
.expect("error response should include detail");
|
|
assert!(detail.contains("dedicated operation endpoint"));
|
|
assert!(detail.contains("run.session.created"));
|
|
|
|
let after_request = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}/events")))
|
|
.body(Body::empty())
|
|
.expect("run-events request should build");
|
|
let after = response_json(
|
|
app.clone().oneshot(after_request).await.unwrap(),
|
|
StatusCode::OK,
|
|
format!("GET /api/v1/runs/{run_id}/events after rejected append"),
|
|
)
|
|
.await;
|
|
assert_eq!(after, before);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sessions_are_listed_only_under_their_owning_run() {
|
|
let app = fabro_server::test_support::build_test_router(test_app_state());
|
|
let first_run_id = create_run(&app).await;
|
|
let second_run_id = create_run(&app).await;
|
|
let created = create_session(&app, &first_run_id, "First run chat").await;
|
|
|
|
let first_request = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{first_run_id}/sessions")))
|
|
.body(Body::empty())
|
|
.expect("list sessions request should build");
|
|
let first = response_json(
|
|
app.clone().oneshot(first_request).await.unwrap(),
|
|
StatusCode::OK,
|
|
format!("GET /api/v1/runs/{first_run_id}/sessions"),
|
|
)
|
|
.await;
|
|
assert_eq!(first["data"].as_array().unwrap().len(), 1);
|
|
assert_eq!(first["data"][0]["id"], created["id"]);
|
|
assert_session_metadata_only(&first["data"][0]);
|
|
|
|
let second_request = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{second_run_id}/sessions")))
|
|
.body(Body::empty())
|
|
.expect("list sessions request should build");
|
|
let second = response_json(
|
|
app.clone().oneshot(second_request).await.unwrap(),
|
|
StatusCode::OK,
|
|
format!("GET /api/v1/runs/{second_run_id}/sessions"),
|
|
)
|
|
.await;
|
|
assert!(second["data"].as_array().unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn supplied_session_model_alias_is_canonicalized() {
|
|
let app = fabro_server::test_support::build_test_router(test_app_state());
|
|
let run_id = create_run(&app).await;
|
|
|
|
let created = create_session_with_model(&app, &run_id, "Ask Fabro", "gpt54").await;
|
|
assert_eq!(created["model"], "gpt-5.4");
|
|
|
|
let events_request = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/runs/{run_id}/events")))
|
|
.body(Body::empty())
|
|
.expect("run-events request should build");
|
|
let events = response_json(
|
|
app.clone().oneshot(events_request).await.unwrap(),
|
|
StatusCode::OK,
|
|
format!("GET /api/v1/runs/{run_id}/events"),
|
|
)
|
|
.await;
|
|
|
|
let created_event = events["data"]
|
|
.as_array()
|
|
.expect("events response should include data")
|
|
.iter()
|
|
.find(|event| event["event"] == "run.session.created")
|
|
.expect("session creation event should be recorded");
|
|
assert_eq!(created_event["properties"]["model"], "gpt-5.4");
|
|
assert_eq!(created_event["properties"]["provider"], "openai");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_qualified_session_model_is_canonicalized() {
|
|
let app = fabro_server::test_support::build_test_router(test_app_state());
|
|
let run_id = create_run(&app).await;
|
|
|
|
let created =
|
|
create_session_with_model(&app, &run_id, "Ask Fabro", "openai/gpt-5.4-mini").await;
|
|
|
|
assert_eq!(created["model"], "gpt-5.4-mini");
|
|
assert_eq!(created["provider"], "openai");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_session_models_preserve_passthrough_on_the_selected_provider() {
|
|
let app = fabro_server::test_support::build_test_router(test_app_state());
|
|
let run_id = create_run(&app).await;
|
|
|
|
for model in ["not-a-real-model", "openai/not-a-real-model"] {
|
|
let created = create_session_with_model(&app, &run_id, "Ask Fabro", model).await;
|
|
assert_eq!(created["model"], "not-a-real-model");
|
|
assert_eq!(created["provider"], "openai");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_session_model_refs_are_rejected_at_creation() {
|
|
let app = fabro_server::test_support::build_test_router(test_app_state());
|
|
let run_id = create_run(&app).await;
|
|
|
|
for model in ["openai", "openai/", "anthropic/gpt-5.4"] {
|
|
let response = create_session_response(
|
|
&app,
|
|
&run_id,
|
|
serde_json::json!({ "title": "Ask Fabro", "model": model }),
|
|
)
|
|
.await;
|
|
response_status(
|
|
response,
|
|
StatusCode::BAD_REQUEST,
|
|
format!("POST /api/v1/runs/{run_id}/sessions with model {model}"),
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ambiguous_session_model_refs_are_rejected_at_creation() {
|
|
let mut catalog_settings = fabro_model::catalog::LlmCatalogSettings::default();
|
|
catalog_settings.providers.insert(
|
|
"openai".to_string(),
|
|
fabro_model::catalog::ProviderCatalogSettings {
|
|
aliases: Some(vec!["gpt54".to_string()]),
|
|
..fabro_model::catalog::ProviderCatalogSettings::default()
|
|
},
|
|
);
|
|
let state = fabro_server::test_support::TestAppStateBuilder::new()
|
|
.llm_catalog_settings(catalog_settings)
|
|
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
|
.build();
|
|
let app = fabro_server::test_support::build_test_router(state);
|
|
let run_id = create_run(&app).await;
|
|
|
|
let response = create_session_response(
|
|
&app,
|
|
&run_id,
|
|
serde_json::json!({ "title": "Ask Fabro", "model": "gpt54" }),
|
|
)
|
|
.await;
|
|
response_status(
|
|
response,
|
|
StatusCode::BAD_REQUEST,
|
|
format!("POST /api/v1/runs/{run_id}/sessions with ambiguous model gpt54"),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_turn_fails_when_selected_model_provider_becomes_unconfigured() {
|
|
let app = fabro_server::test_support::build_test_router(test_app_state());
|
|
let run_id = create_run(&app).await;
|
|
let created = create_session_with_model(&app, &run_id, "Ask Fabro", "gpt54").await;
|
|
let session_id = created["id"]
|
|
.as_str()
|
|
.expect("session response should include an id");
|
|
delete_openai_credential(&app).await;
|
|
|
|
let request = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/sessions/{session_id}/turns")))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"input":"Which provider are you using?"}"#))
|
|
.expect("submit-turn request should build");
|
|
let response = app.clone().oneshot(request).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert!(
|
|
response.headers().contains_key("x-fabro-turn-id"),
|
|
"submit turn should return the generated turn id"
|
|
);
|
|
let events = session_sse_events(response).await;
|
|
|
|
let failed = events
|
|
.iter()
|
|
.find(|event| event["event"] == "run.session.turn.failed")
|
|
.expect("selected provider failure should be streamed");
|
|
assert!(
|
|
failed["properties"]["error"]
|
|
.as_str()
|
|
.expect("failure event should include an error")
|
|
.contains("provider 'openai'"),
|
|
"failure should be for the selected model provider: {failed:?}"
|
|
);
|
|
assert_eq!(failed["properties"]["code"], "model_unavailable");
|
|
assert_eq!(failed["properties"]["retryable"], false);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_metadata_patch_route_is_removed() {
|
|
let app = fabro_server::test_support::build_test_router(test_app_state());
|
|
let run_id = create_run(&app).await;
|
|
let created = create_session(&app, &run_id, "Ask Fabro").await;
|
|
let session_id = created["id"]
|
|
.as_str()
|
|
.expect("session response should include an id");
|
|
|
|
let request = Request::builder()
|
|
.method("PATCH")
|
|
.uri(api(&format!("/sessions/{session_id}")))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"title":"Renamed"}"#))
|
|
.expect("patch-session request should build");
|
|
response_status(
|
|
app.clone().oneshot(request).await.unwrap(),
|
|
StatusCode::NOT_FOUND,
|
|
format!("PATCH /api/v1/sessions/{session_id}"),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unsupported_derived_turn_read_routes_are_removed() {
|
|
let app = fabro_server::test_support::build_test_router(test_app_state());
|
|
let run_id = create_run(&app).await;
|
|
let created = create_session(&app, &run_id, "Ask Fabro").await;
|
|
let session_id = created["id"]
|
|
.as_str()
|
|
.expect("session response should include an id");
|
|
let turn_id = fabro_types::TurnId::new();
|
|
|
|
for path in [format!("/sessions/{session_id}/turns/{turn_id}")] {
|
|
let request = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&path))
|
|
.body(Body::empty())
|
|
.expect("removed session read request should build");
|
|
response_status(
|
|
app.clone().oneshot(request).await.unwrap(),
|
|
StatusCode::NOT_FOUND,
|
|
format!("GET /api/v1{path}"),
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_events_are_filtered_by_session_and_paginated_by_run_sequence() {
|
|
let app = fabro_server::test_support::build_test_router(test_app_state());
|
|
let run_id = create_run(&app).await;
|
|
let first = create_session(&app, &run_id, "First").await;
|
|
let second = create_session(&app, &run_id, "Second").await;
|
|
let first_id = first["id"].as_str().unwrap();
|
|
let second_id = second["id"].as_str().unwrap();
|
|
let get_first = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!("/sessions/{first_id}")))
|
|
.body(Body::empty())
|
|
.expect("get-session request should build");
|
|
let first_detail = response_json(
|
|
app.clone().oneshot(get_first).await.unwrap(),
|
|
StatusCode::OK,
|
|
format!("GET /api/v1/sessions/{first_id}"),
|
|
)
|
|
.await;
|
|
let after_first_created_seq = first_detail["last_seq"].as_u64().unwrap() + 1;
|
|
delete_openai_credential(&app).await;
|
|
|
|
let turn_id = fabro_types::TurnId::new();
|
|
let submit = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/sessions/{first_id}/turns")))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(format!(
|
|
r#"{{"turn_id":"{turn_id}","input":"Which provider?"}}"#
|
|
)))
|
|
.expect("submit-turn request should build");
|
|
let response = app.clone().oneshot(submit).await.unwrap();
|
|
assert_eq!(
|
|
response.headers().get("x-fabro-turn-id").unwrap(),
|
|
turn_id.to_string().as_str()
|
|
);
|
|
let _ = session_sse_events(response).await;
|
|
|
|
let request = Request::builder()
|
|
.method("GET")
|
|
.uri(api(&format!(
|
|
"/sessions/{first_id}/events?since_seq={after_first_created_seq}&limit=1"
|
|
)))
|
|
.body(Body::empty())
|
|
.expect("session events request should build");
|
|
let page = response_json(
|
|
app.clone().oneshot(request).await.unwrap(),
|
|
StatusCode::OK,
|
|
format!("GET /api/v1/sessions/{first_id}/events"),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(page["data"].as_array().unwrap().len(), 1);
|
|
assert_eq!(page["data"][0]["session_id"], first_id);
|
|
assert_ne!(page["data"][0]["session_id"], second_id);
|
|
assert_eq!(page["data"][0]["event"], "run.session.turn.started");
|
|
assert_eq!(
|
|
page["data"][0]["properties"]["turn_id"],
|
|
turn_id.to_string()
|
|
);
|
|
assert_eq!(page["meta"]["has_more"], true);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn inactive_turn_interrupt_returns_conflict() {
|
|
let app = fabro_server::test_support::build_test_router(test_app_state());
|
|
let run_id = create_run(&app).await;
|
|
let created = create_session(&app, &run_id, "Ask Fabro").await;
|
|
let session_id = created["id"]
|
|
.as_str()
|
|
.expect("session response should include an id");
|
|
let turn_id = fabro_types::TurnId::new();
|
|
|
|
let request = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!(
|
|
"/sessions/{session_id}/turns/{turn_id}/interrupt"
|
|
)))
|
|
.body(Body::empty())
|
|
.expect("interrupt request should build");
|
|
response_status(
|
|
app.clone().oneshot(request).await.unwrap(),
|
|
StatusCode::CONFLICT,
|
|
format!("POST /api/v1/sessions/{session_id}/turns/{turn_id}/interrupt"),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn session_sse_events(response: axum::response::Response) -> Vec<serde_json::Value> {
|
|
let bytes = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("session event stream body should be readable");
|
|
let body = String::from_utf8(bytes.to_vec()).expect("session event stream should be UTF-8");
|
|
body.lines()
|
|
.filter_map(|line| line.strip_prefix("data: "))
|
|
.map(|data| serde_json::from_str(data).expect("session event data should be JSON"))
|
|
.collect()
|
|
}
|
|
|
|
async fn delete_openai_credential(app: &axum::Router) {
|
|
let request = Request::builder()
|
|
.method("DELETE")
|
|
.uri(api("/secrets"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&serde_json::json!({
|
|
"name": EnvVars::OPENAI_API_KEY,
|
|
}))
|
|
.expect("delete-secret request should serialize"),
|
|
))
|
|
.expect("delete-secret request should build");
|
|
response_status(
|
|
app.clone().oneshot(request).await.unwrap(),
|
|
StatusCode::NO_CONTENT,
|
|
"DELETE /api/v1/secrets",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
fn assert_session_metadata_only(value: &serde_json::Value) {
|
|
let object = value
|
|
.as_object()
|
|
.expect("session response should be a JSON object");
|
|
assert_eq!(value["provider"], "openai");
|
|
for field in [
|
|
"working_dir",
|
|
"permissions",
|
|
"deleted_at",
|
|
"runtime_context",
|
|
] {
|
|
assert!(
|
|
!object.contains_key(field),
|
|
"session metadata should not expose {field}"
|
|
);
|
|
}
|
|
}
|