fabro/lib/crates/fabro-api/tests/openapi_conformance.rs
Bryan Helmkamp 6c3607440f Extract fabro-hooks crate from fabro-workflows
Move the self-contained hooks module (~2900 LOC) into its own crate to
clarify the dependency graph and make the hook system independently
reusable. The set_node convenience method is inlined at its two call
sites in parallel.rs since it depends on fabro-graphviz types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:08:02 -04:00

402 lines
13 KiB
Rust

//! Conformance tests: spec ↔ router ↔ Rust struct consistency.
use std::collections::BTreeSet;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use fabro_api::jwt_auth::AuthMode;
use fabro_api::server::{build_router, create_app_state};
use fabro_api::server_config::*;
use fabro_daytona::*;
use fabro_hooks::*;
use fabro_interview::Interviewer;
use fabro_workflows::cli::run_config::*;
use fabro_workflows::handler::exit::ExitHandler;
use fabro_workflows::handler::start::StartHandler;
use fabro_workflows::handler::HandlerRegistry;
use tower::ServiceExt;
fn test_registry(_interviewer: Arc<dyn Interviewer>) -> HandlerRegistry {
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry
}
async fn test_db() -> sqlx::SqlitePool {
let pool = fabro_db::connect_memory().await.unwrap();
fabro_db::initialize_db(&pool).await.unwrap();
pool
}
fn load_spec() -> openapiv3::OpenAPI {
let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join("docs/api-reference/fabro-api.yaml");
let text = std::fs::read_to_string(&spec_path).expect("failed to read spec");
serde_yaml::from_str(&text).expect("failed to parse spec")
}
fn resolve_path(path: &str) -> String {
path.replace("{id}", "test-id")
.replace("{qid}", "test-qid")
.replace("{stageId}", "test-stage")
.replace("{name}", "test-name")
.replace("{slug}", "test-slug")
}
fn methods_for_path_item(item: &openapiv3::PathItem) -> Vec<Method> {
let mut methods = Vec::new();
if item.get.is_some() {
methods.push(Method::GET);
}
if item.post.is_some() {
methods.push(Method::POST);
}
if item.put.is_some() {
methods.push(Method::PUT);
}
if item.delete.is_some() {
methods.push(Method::DELETE);
}
if item.patch.is_some() {
methods.push(Method::PATCH);
}
methods
}
#[tokio::test]
async fn all_spec_routes_are_routable() {
let spec = load_spec();
let state = create_app_state(test_db().await, test_registry);
let app = build_router(state, AuthMode::Disabled);
let mut checked = 0;
for (path, item) in &spec.paths.paths {
let path_item = match item {
openapiv3::ReferenceOr::Item(item) => item,
openapiv3::ReferenceOr::Reference { .. } => continue,
};
let uri = resolve_path(path);
for method in methods_for_path_item(path_item) {
let mut builder = Request::builder().method(&method).uri(&uri);
let body = if method == Method::POST {
builder = builder.header("content-type", "application/json");
Body::from("{}")
} else {
Body::empty()
};
let req = builder.body(body).unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_ne!(
response.status(),
StatusCode::METHOD_NOT_ALLOWED,
"Route {method} {path} returned 405 — not registered in the router"
);
checked += 1;
}
}
assert!(checked > 0, "No routes were checked — is the spec empty?");
}
// ── ServerConfig ↔ OpenAPI schema drift detection ──────────────────────
/// Load the spec as serde_json::Value for schema introspection.
fn load_spec_json() -> serde_json::Value {
let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join("docs/api-reference/fabro-api.yaml");
let text = std::fs::read_to_string(&spec_path).expect("read spec");
serde_yaml::from_str(&text).expect("parse spec")
}
/// Follow a `$ref` pointer, or return the value unchanged.
fn resolve_ref<'a>(
value: &'a serde_json::Value,
root: &'a serde_json::Value,
) -> &'a serde_json::Value {
match value.get("$ref").and_then(|v| v.as_str()) {
Some(ref_str) => {
let mut cur = root;
for seg in ref_str.trim_start_matches("#/").split('/') {
cur = &cur[seg];
}
cur
}
None => value,
}
}
/// Collect property names from an OpenAPI schema object.
fn spec_keys(schema: &serde_json::Value) -> BTreeSet<String> {
schema
.get("properties")
.and_then(|p| p.as_object())
.map(|m| m.keys().cloned().collect())
.unwrap_or_default()
}
/// Recursively compare serialized JSON keys against OpenAPI schema properties.
fn compare_schema(
path: &str,
json: &serde_json::Value,
schema: &serde_json::Value,
root: &serde_json::Value,
errors: &mut Vec<String>,
) {
let obj = match json.as_object() {
Some(o) => o,
None => return,
};
// Skip pure-map schemas (additionalProperties without properties).
if schema.get("additionalProperties").is_some() && schema.get("properties").is_none() {
return;
}
let json_keys: BTreeSet<String> = obj.keys().cloned().collect();
let schema_keys = spec_keys(schema);
for key in json_keys.difference(&schema_keys) {
errors.push(format!(
"{path}.{key}: in Rust but missing from OpenAPI spec"
));
}
for key in schema_keys.difference(&json_keys) {
errors.push(format!(
"{path}.{key}: in OpenAPI spec but missing from Rust"
));
}
let properties = match schema.get("properties").and_then(|p| p.as_object()) {
Some(p) => p,
None => return,
};
for key in json_keys.intersection(&schema_keys) {
let json_val = &obj[key];
let prop_schema = resolve_ref(&properties[key], root);
// Skip maps and union types.
if prop_schema.get("additionalProperties").is_some() || prop_schema.get("oneOf").is_some() {
continue;
}
match json_val {
serde_json::Value::Object(_) => {
compare_schema(
&format!("{path}.{key}"),
json_val,
prop_schema,
root,
errors,
);
}
serde_json::Value::Array(arr) => {
// Union keys across all array elements.
let union: BTreeSet<String> = arr
.iter()
.filter_map(|e| e.as_object())
.flat_map(|o| o.keys().cloned())
.collect();
if union.is_empty() {
continue;
}
let items = match prop_schema.get("items") {
Some(i) => resolve_ref(i, root),
None => continue,
};
let synthetic = serde_json::Value::Object(
union
.into_iter()
.map(|k| (k, serde_json::Value::Null))
.collect(),
);
compare_schema(&format!("{path}.{key}[]"), &synthetic, items, root, errors);
}
_ => {}
}
}
}
/// Build a ServerConfig with every Option set to Some so all keys appear
/// in the serialized JSON.
fn fully_populated_server_config() -> ServerConfig {
ServerConfig {
data_dir: Some("/data".into()),
max_concurrent_runs: Some(10),
web: WebConfig {
url: "https://example.com".into(),
auth: AuthConfig {
provider: AuthProvider::Github,
allowed_usernames: vec!["user".into()],
},
},
api: ApiConfig {
base_url: "https://api.example.com".into(),
authentication_strategies: vec![ApiAuthStrategy::Jwt],
tls: Some(TlsConfig {
cert: "c".into(),
key: "k".into(),
ca: "ca".into(),
}),
},
git: GitConfig {
provider: GitProvider::Github,
app_id: Some("123".into()),
client_id: Some("456".into()),
slug: Some("fabro".into()),
author: GitAuthorConfig {
name: Some("bot".into()),
email: Some("bot@x".into()),
},
webhooks: Some(WebhookConfig {
strategy: WebhookStrategy::TailscaleFunnel,
}),
},
features: Features {
session_sandboxes: true,
retros: false,
},
log: LogConfig {
level: Some("debug".into()),
},
run_defaults: RunDefaults {
work_dir: Some("/work".into()),
llm: Some(LlmConfig {
model: Some("m".into()),
provider: Some("p".into()),
fallbacks: Some(Default::default()),
}),
setup: Some(SetupConfig {
commands: vec!["echo hi".into()],
timeout_ms: Some(5000),
}),
sandbox: Some(SandboxConfig {
provider: Some("daytona".into()),
preserve: Some(true),
devcontainer: None,
local: None,
daytona: Some(DaytonaConfig {
auto_stop_interval: Some(60),
labels: Some(Default::default()),
snapshot: Some(DaytonaSnapshotConfig {
name: "snap".into(),
cpu: Some(2),
memory: Some(4),
disk: Some(10),
dockerfile: Some(DockerfileSource::Inline("FROM x".into())),
}),
network: Some(DaytonaNetwork::Block),
}),
exe: Some(fabro_exe::ExeConfig { image: None }),
ssh: None,
env: Some(Default::default()),
}),
vars: Some(Default::default()),
checkpoint: CheckpointConfig {
exclude_globs: vec![],
},
pull_request: Some(PullRequestConfig {
enabled: true,
draft: false,
auto_merge: false,
merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
}),
assets: Some(AssetsConfig {
include: vec!["test-results/**".into()],
}),
// One hook per HookType variant so the key union covers all fields.
hooks: vec![
HookDefinition {
name: Some("cmd".into()),
event: HookEvent::RunStart,
command: Some("echo".into()),
hook_type: None,
matcher: Some("*".into()),
blocking: Some(true),
timeout_ms: Some(5000),
sandbox: Some(true),
},
HookDefinition {
name: Some("http".into()),
event: HookEvent::RunStart,
command: None,
hook_type: Some(HookType::Http {
url: "http://x".into(),
headers: Some(Default::default()),
allowed_env_vars: vec!["X".into()],
tls: TlsMode::Verify,
}),
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
},
HookDefinition {
name: Some("prompt".into()),
event: HookEvent::RunStart,
command: None,
hook_type: Some(HookType::Prompt {
prompt: "hi".into(),
model: Some("m".into()),
}),
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
},
HookDefinition {
name: Some("agent".into()),
event: HookEvent::RunStart,
command: None,
hook_type: Some(HookType::Agent {
prompt: "hi".into(),
model: Some("m".into()),
max_tool_rounds: Some(5),
}),
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
},
],
mcp_servers: Default::default(),
github: None,
},
}
}
#[test]
fn server_config_keys_match_openapi_spec() {
let config = fully_populated_server_config();
let json = serde_json::to_value(&config).expect("serialize ServerConfig");
let spec = load_spec_json();
let schema = &spec["components"]["schemas"]["ServerConfiguration"];
let mut errors = Vec::new();
compare_schema("ServerConfiguration", &json, schema, &spec, &mut errors);
if !errors.is_empty() {
panic!(
"ServerConfig ↔ OpenAPI schema drift:\n {}",
errors.join("\n ")
);
}
}