//! 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) -> 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 { 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 { 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, ) { 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 = 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 = 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 ") ); } }