//! A host that is nothing but the trait: a temp database, a fixed client id, //! a fixed session. It exists to prove that the handlers really do run without //! a desktop around them, and that the client's identity reaches the writes. //! //! Neither host here has a plugin runtime — no `PluginManager`, no sidecar. //! `TestHost` implements `Host` alone, so a handler that reaches for plugins //! would not compile against it. `SingleThreadedHost` goes further and answers //! `PluginHost` too, without one, which is only possible because that trait //! names operations rather than handing back a manager. use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; use std::sync::{Arc, Mutex}; use tempfile::TempDir; use yaak_commands::auth::cmd_get_http_authentication_config; use yaak_commands::models::{ cmd_default_headers, cmd_get_workspace_meta, models_delete, models_upsert, models_workspace_models, }; use yaak_commands::templates::cmd_render_template; use yaak_commands::{Host, PluginHost}; use yaak_core::WorkspaceContext; use yaak_crypto::manager::EncryptionManager; use yaak_models::blob_manager::BlobManager; use yaak_models::models::{AnyModel, Environment, EnvironmentVariable, Plugin, Workspace}; use yaak_models::query_manager::QueryManager; use yaak_models::util::{ModelPayload, UpdateSource}; use yaak_plugins::events::{ CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest, CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportResponse, JsonPrimitive, RenderPurpose, }; use yaak_plugins::plugin_meta::PluginMetadata; use yaak_rpc_schema::{ CmdDefaultHeadersReq, CmdGetWorkspaceMetaReq, CmdRenderTemplateReq, ModelsDeleteReq, ModelsUpsertReq, ModelsWorkspaceModelsReq, }; use yaak_templates::TemplateCallback; #[derive(Clone)] struct TestHost { inner: Arc, } struct Inner { _dir: TempDir, query_manager: QueryManager, blob_manager: BlobManager, encryption_manager: EncryptionManager, /// Every model write the database reported, so a test can check who it /// says made them. writes: Mutex>, rx: Mutex>, } impl TestHost { fn new() -> Self { let dir = TempDir::new().expect("temp dir"); let (query_manager, blob_manager, rx) = yaak_models::init_standalone( dir.path().join("db.sqlite"), dir.path().join("blobs.sqlite"), ) .expect("init db"); let encryption_manager = EncryptionManager::new(query_manager.clone(), "app.yaak.test"); Self { inner: Arc::new(Inner { _dir: dir, query_manager, blob_manager, encryption_manager, writes: Mutex::new(Vec::new()), rx: Mutex::new(rx), }), } } fn drain_writes(&self) -> Vec { let rx = self.inner.rx.lock().unwrap(); let mut writes = self.inner.writes.lock().unwrap(); while let Ok(payload) = rx.try_recv() { writes.push(payload); } writes.drain(..).collect() } } impl Host for TestHost { fn client_id(&self) -> &str { "test-client" } fn session(&self) -> WorkspaceContext { WorkspaceContext::new().with_workspace("wk_test") } fn app_version(&self) -> String { "0.0.0-test".to_string() } fn query_manager(&self) -> &QueryManager { &self.inner.query_manager } fn blob_manager(&self) -> &BlobManager { &self.inner.blob_manager } fn encryption_manager(&self) -> &EncryptionManager { &self.inner.encryption_manager } } #[tokio::test(flavor = "multi_thread")] async fn writes_carry_the_client_id() { let host = TestHost::new(); let workspace = Workspace { name: "From a test".to_string(), ..Default::default() }; let id = models_upsert(host.clone(), ModelsUpsertReq { model: AnyModel::Workspace(workspace) }) .await .expect("upsert"); assert!(id.starts_with("wk_"), "unexpected id {id}"); let writes = host.drain_writes(); assert_eq!(writes.len(), 1); assert!( matches!(&writes[0].update_source, UpdateSource::Window { label } if label == "test-client"), "the write should be attributed to the calling client, got {:?}", writes[0].update_source, ); let meta = cmd_get_workspace_meta(host.clone(), CmdGetWorkspaceMetaReq { workspace_id: id.clone() }) .await .expect("workspace meta"); assert_eq!(meta.workspace_id, id); // Deletes cascade inside a transaction; make sure that path works with no // host doing anything special around it. let workspace = host.db().get_workspace(&id).expect("get workspace"); let deleted = models_delete(host.clone(), ModelsDeleteReq { model: AnyModel::Workspace(workspace) }) .await .expect("delete"); assert_eq!(deleted, id); assert!(host.db().get_workspace(&id).is_err(), "workspace should be gone"); } #[tokio::test] async fn host_free_handlers_need_no_state() { let host = TestHost::new(); let headers = cmd_default_headers(host, CmdDefaultHeadersReq {}).await.expect("headers"); assert!(!headers.is_empty()); } /// A host that is deliberately **not** `Send` or `Sync`: it keeps its state in /// an `Rc`, the way a single-threaded browser host has to, since /// `rusqlite::Connection` is not `Sync` to begin with. It also has no plugin /// runtime of any kind — no `PluginManager`, no sidecar, nothing to spawn. /// /// Nothing here asserts much at runtime; the test is largely that it compiles. /// A `Host` demanding thread-safety, or a `PluginHost` handing back a /// `&PluginManager`, would shut such a host out of the traits entirely and this /// file would stop building. #[derive(Clone)] struct SingleThreadedHost { inner: Rc, /// The values the last auth-config call arrived with, so a test can check /// they were rendered before the host ever saw them. auth_values: Rc>>>, } impl Host for SingleThreadedHost { fn client_id(&self) -> &str { "tab-1" } fn session(&self) -> WorkspaceContext { WorkspaceContext::new() } fn app_version(&self) -> String { "0.0.0-web".to_string() } fn query_manager(&self) -> &QueryManager { &self.inner.query_manager } fn blob_manager(&self) -> &BlobManager { &self.inner.blob_manager } fn encryption_manager(&self) -> &EncryptionManager { &self.inner.encryption_manager } } /// A template callback with no plugins behind it: variables still resolve, /// function calls have nothing to run them. A browser host would put a Worker /// round-trip where this returns an error. struct NoTemplateFunctions; impl TemplateCallback for NoTemplateFunctions { async fn run( &self, fn_name: &str, _args: HashMap, ) -> yaak_templates::error::Result { Err(yaak_templates::error::Error::RenderError(format!( "no plugin runtime to run {fn_name}()" ))) } fn transform_arg( &self, _fn_name: &str, _arg_name: &str, arg_value: &str, ) -> yaak_templates::error::Result { Ok(arg_value.to_string()) } } /// Answering plugin questions with no plugin runtime behind them. A browser /// host would put a `postMessage` round-trip to its Worker where these return /// constants; the shape of the trait is what makes either possible. impl PluginHost for SingleThreadedHost { async fn loaded_plugin_metadata(&self, _directory: &str) -> Option { None } async fn take_plugin_init_errors(&self) -> Vec<(String, String)> { Vec::new() } async fn resolve_plugins(&self, plugins: Vec) -> Vec { // No runtime to enrich them with; the database rows are still the truth // about what is installed. plugins } async fn encrypt_secure_template(&self, _template: &str) -> yaak_commands::Result { Err(yaak_commands::Error::Generic("no plugin runtime on this host".into())) } fn template_callback(&self, _purpose: RenderPurpose) -> impl TemplateCallback { NoTemplateFunctions } async fn template_function_summaries( &self, ) -> yaak_commands::Result> { Ok(Vec::new()) } async fn template_function_config( &self, function_name: &str, _values: HashMap, _model_id: &str, ) -> yaak_commands::Result { Err(yaak_commands::Error::Generic(format!("no plugin provides {function_name}()"))) } async fn themes(&self) -> yaak_commands::Result> { Ok(Vec::new()) } // No plugins, so nothing contributes actions and nothing can run one. async fn http_request_actions( &self, ) -> yaak_commands::Result> { Ok(Vec::new()) } async fn websocket_request_actions( &self, ) -> yaak_commands::Result> { Ok(Vec::new()) } async fn grpc_request_actions( &self, ) -> yaak_commands::Result> { Ok(Vec::new()) } async fn workspace_actions(&self) -> yaak_commands::Result> { Ok(Vec::new()) } async fn folder_actions(&self) -> yaak_commands::Result> { Ok(Vec::new()) } async fn call_http_request_action( &self, _req: CallHttpRequestActionRequest, ) -> yaak_commands::Result<()> { Err(no_plugins()) } async fn call_grpc_request_action( &self, _req: CallGrpcRequestActionRequest, ) -> yaak_commands::Result<()> { Err(no_plugins()) } async fn call_websocket_request_action( &self, _req: CallWebsocketRequestActionRequest, ) -> yaak_commands::Result<()> { Err(no_plugins()) } async fn call_workspace_action( &self, _req: CallWorkspaceActionRequest, ) -> yaak_commands::Result<()> { Err(no_plugins()) } async fn call_folder_action(&self, _req: CallFolderActionRequest) -> yaak_commands::Result<()> { Err(no_plugins()) } async fn http_authentication_summaries( &self, ) -> yaak_commands::Result> { Ok(Vec::new()) } async fn http_authentication_config( &self, _auth_name: &str, values: HashMap, _model_id: &str, ) -> yaak_commands::Result { *self.auth_values.borrow_mut() = Some(values); Err(no_plugins()) } async fn call_http_authentication_action( &self, _auth_name: &str, _action_index: i32, _values: HashMap, _model_id: &str, ) -> yaak_commands::Result<()> { Err(no_plugins()) } async fn import_data(&self, _content: &str) -> yaak_commands::Result { Err(no_plugins()) } async fn reload_plugins(&self, _plugins: Vec) -> Vec<(String, String)> { Vec::new() } } fn no_plugins() -> yaak_commands::Error { yaak_commands::Error::Generic("no plugin runtime on this host".into()) } #[tokio::test] async fn a_single_threaded_host_can_implement_the_trait() { let TestHost { inner } = TestHost::new(); let host = SingleThreadedHost { inner: Rc::new(Arc::into_inner(inner).expect("sole owner")), auth_values: Rc::new(RefCell::new(None)), }; let workspace = Workspace { name: "From one thread".to_string(), ..Default::default() }; let id = models_upsert(host.clone(), ModelsUpsertReq { model: AnyModel::Workspace(workspace) }) .await .expect("upsert"); // A `PluginHost` command, on a host with no plugin runtime at all. This is // the one that could not be written when the trait handed back a // `&PluginManager`. let json = models_workspace_models( host.clone(), ModelsWorkspaceModelsReq { workspace_id: Some(id.clone()) }, ) .await .expect("workspace models"); assert!(json.contains(&id), "the workspace should be in its own bootstrap payload"); // Rendering, on a host whose template callback has no plugins behind it. // Resolving the environment chain is a database read and the render is // shared code; only the callback came from the host. Rendering a real // variable is what proves the chain was resolved rather than skipped. let environment = host .db() .upsert_environment( &Environment { workspace_id: id.clone(), name: "Test env".to_string(), variables: vec![EnvironmentVariable { enabled: true, name: "greeting".to_string(), value: "hello".to_string(), id: None, }], ..Default::default() }, &host.update_source(), ) .expect("seed environment"); let rendered = cmd_render_template( host.clone(), CmdRenderTemplateReq { template: "${[ greeting ]} world".to_string(), workspace_id: id.clone(), environment_id: Some(environment.id.clone()), purpose: None, ignore_error: None, }, ) .await .expect("render"); assert_eq!(rendered, "hello world", "the environment chain should have been resolved"); // The delete path too, since it is the one that used to reach for a // blocking thread this host does not have. let workspace = host.db().get_workspace(&id).expect("get workspace"); let deleted = models_delete(host, ModelsDeleteReq { model: AnyModel::Workspace(workspace) }) .await .expect("delete"); assert_eq!(deleted, id); } /// Auth form values may contain templates, and a plugin must never see one /// unrendered. The rendering happens in the shared handler, so this checks the /// host received a resolved value rather than `${[ ... ]}`. #[tokio::test] async fn auth_values_are_rendered_before_the_host_sees_them() { let TestHost { inner } = TestHost::new(); let host = SingleThreadedHost { inner: Rc::new(Arc::into_inner(inner).expect("sole owner")), auth_values: Rc::new(RefCell::new(None)), }; let workspace = host .db() .upsert_workspace( &Workspace { name: "Auth".to_string(), ..Default::default() }, &host.update_source(), ) .expect("workspace"); host.db() .upsert_environment( &Environment { workspace_id: workspace.id.clone(), name: "Env".to_string(), variables: vec![EnvironmentVariable { enabled: true, name: "token".to_string(), value: "s3cret".to_string(), id: None, }], ..Default::default() }, &host.update_source(), ) .expect("environment"); let environment = host.db().list_environments_ensure_base(&workspace.id).expect("list").remove(0); let mut values = HashMap::new(); values.insert("password".to_string(), JsonPrimitive::String("${[ token ]}".to_string())); // The host refuses the call itself — it has no plugins — but only after the // handler has rendered and handed over the values, which is what matters. let _ = cmd_get_http_authentication_config( host.clone(), yaak_rpc_schema::CmdGetHttpAuthenticationConfigReq { auth_name: "basic".to_string(), values, model: AnyModel::Workspace(workspace), environment_id: Some(environment.id), }, ) .await; let seen = host.auth_values.borrow().clone().expect("the host should have been called"); assert!( matches!(seen.get("password"), Some(JsonPrimitive::String(v)) if v == "s3cret"), "the template should have been rendered before reaching the host, got {:?}", seen.get("password"), ); }