//! Integration tests for the plugin validation pipeline. //! //! Renderer-level behaviour (e.g., Dioxus components) is out of scope here; //! that requires a Dioxus runtime and belongs in pinakes-ui tests. use std::collections::HashMap; use pinakes_plugin_api::{ DataSource, HttpMethod, UiElement, UiPage, UiWidget, validation::SchemaValidator, }; // Build a minimal valid UiPage. fn make_page(id: &str, route: &str) -> UiPage { UiPage { id: id.to_string(), title: "Integration Test Page".to_string(), route: route.to_string(), icon: None, root_element: UiElement::Container { children: vec![], gap: 0, padding: None, }, data_sources: HashMap::new(), actions: HashMap::new(), } } // Build a minimal valid UiWidget. fn make_widget(id: &str, target: &str) -> UiWidget { UiWidget { id: id.to_string(), target: target.to_string(), content: UiElement::Container { children: vec![], gap: 0, padding: None, }, } } // Build a complete valid PluginManifest as TOML string. const fn valid_manifest_toml() -> &'static str { r#" [plugin] name = "integration-test-plugin" version = "1.0.0" api_version = "1.0" kind = ["ui_page"] [plugin.binary] wasm = "plugin.wasm" [[ui.pages]] id = "stats" title = "Statistics" route = "/plugins/integration-test/stats" [ui.pages.layout] type = "container" children = [] gap = 0 [[ui.widgets]] id = "status-badge" target = "library_header" [ui.widgets.content] type = "badge" text = "online" "# } // A complete valid manifest (with a page and a widget) passes all // checks. #[test] fn test_full_valid_plugin_manifest_passes_all_checks() { use pinakes_plugin_api::PluginManifest; let manifest = PluginManifest::parse_str(valid_manifest_toml()) .expect("manifest must parse"); assert_eq!(manifest.plugin.name, "integration-test-plugin"); assert_eq!(manifest.ui.pages.len(), 1); assert_eq!(manifest.ui.widgets.len(), 1); // validate page via UiPage::validate let page = make_page("my-plugin-page", "/plugins/integration-test/overview"); page .validate() .expect("valid page must pass UiPage::validate"); // validate the same page via SchemaValidator SchemaValidator::validate_page(&page) .expect("valid page must pass SchemaValidator::validate_page"); // validate widget let widget = make_widget("my-widget", "library_header"); SchemaValidator::validate_widget(&widget) .expect("valid widget must pass SchemaValidator::validate_widget"); } // A page with a reserved route is rejected by both UiPage::validate // and SchemaValidator::validate_page. // // The reserved routes exercised here are "/search" and "/admin". #[test] fn test_page_with_reserved_route_rejected() { let reserved = ["/search", "/admin", "/settings", "/library", "/books"]; for route in reserved { // UiPage::validate path let page = make_page("my-page", route); let result = page.validate(); assert!( result.is_err(), "UiPage::validate must reject reserved route: {route}" ); let msg = result.unwrap_err().to_string(); assert!( msg.contains("conflicts with a built-in app route"), "error for {route} must mention 'conflicts with a built-in app route', \ got: {msg}" ); // SchemaValidator::validate_page path let page2 = make_page("my-page", route); let result2 = SchemaValidator::validate_page(&page2); assert!( result2.is_err(), "SchemaValidator::validate_page must reject reserved route: {route}" ); } } // Sub-paths of reserved routes are also rejected. #[test] fn test_page_with_reserved_route_subpath_rejected() { let subpaths = [ "/search/advanced", "/admin/users", "/settings/theme", "/library/foo", ]; for route in subpaths { let page = make_page("my-page", route); assert!( page.validate().is_err(), "reserved route sub-path must be rejected: {route}" ); } } // A UiWidget whose content contains a DataTable fails validation. // // Widgets have no data-fetching mechanism; any data source reference in a // widget content tree must be caught at load time. #[test] fn test_widget_with_datatable_fails_validation() { use pinakes_plugin_api::ColumnDef; let col: ColumnDef = serde_json::from_value(serde_json::json!({"key": "id", "header": "ID"})) .unwrap(); let widget = UiWidget { id: "bad-widget".to_string(), target: "library_header".to_string(), content: UiElement::DataTable { data: "items".to_string(), columns: vec![col], sortable: false, filterable: false, page_size: 0, row_actions: vec![], }, }; let result = SchemaValidator::validate_widget(&widget); assert!( result.is_err(), "widget with DataTable content must fail validation" ); let msg = result.unwrap_err().to_string(); assert!( msg.contains("cannot reference data sources"), "error must mention data sources, got: {msg}" ); } // A UiWidget whose content is a Container wrapping a Loop fails // validation. Basically a recursive data source check. #[test] fn test_widget_container_wrapping_loop_fails_validation() { use pinakes_plugin_api::Expression; let widget = UiWidget { id: "loop-widget".to_string(), target: "library_header".to_string(), content: UiElement::Container { children: vec![UiElement::Loop { data: "items".to_string(), template: Box::new(UiElement::Text { content: Default::default(), variant: Default::default(), allow_html: false, }), empty: None, }], gap: 0, padding: None, }, }; let result = SchemaValidator::validate_widget(&widget); assert!( result.is_err(), "widget containing a Loop must fail validation" ); let _ = Expression::default(); // Expression should be accessible from the import path } // Endpoint data source with a path that does not start with /api/ fails // UiPage::validate (DataSource::validate is called there). #[test] fn test_endpoint_data_source_non_api_path_rejected() { let mut page = make_page("my-page", "/plugins/test/page"); page .data_sources .insert("items".to_string(), DataSource::Endpoint { path: "/v1/media".to_string(), method: HttpMethod::Get, params: Default::default(), poll_interval: 0, transform: None, }); // DataSource::validate requires /api/ prefix assert!( page.validate().is_err(), "data source path not starting with /api/ must be rejected" ); // Also verify SchemaValidator::validate_page rejects it let result2 = SchemaValidator::validate_page(&page); assert!( result2.is_err(), "SchemaValidator should reject non-/api/ endpoint path" ); } // A static data source with any value passes validation on its own. #[test] fn test_static_data_source_passes_validation() { use pinakes_plugin_api::ColumnDef; let col: ColumnDef = serde_json::from_value(serde_json::json!({"key": "n", "header": "N"})) .unwrap(); let mut page = make_page("my-page", "/plugins/test/page"); page .data_sources .insert("nums".to_string(), DataSource::Static { value: serde_json::json!([1, 2, 3]), }); // Root element references the static data source so DataTable passes page.root_element = UiElement::DataTable { data: "nums".to_string(), columns: vec![col], sortable: false, filterable: false, page_size: 0, row_actions: vec![], }; page .validate() .expect("page with static data source must pass validation"); } // Parse TOML string, validate, and load inline pages round-trips without // errors. #[test] fn test_manifest_inline_page_roundtrip() { use pinakes_plugin_api::{PluginManifest, manifest::UiPageEntry}; let toml = r#" [plugin] name = "roundtrip-plugin" version = "2.3.0" api_version = "1.0" kind = ["ui_page"] [plugin.binary] wasm = "plugin.wasm" [[ui.pages]] id = "overview" title = "Overview" route = "/plugins/roundtrip/overview" [ui.pages.layout] type = "container" children = [] gap = 8 "#; let manifest = PluginManifest::parse_str(toml).expect("manifest must parse"); assert_eq!(manifest.plugin.name, "roundtrip-plugin"); assert_eq!(manifest.ui.pages.len(), 1); match &manifest.ui.pages[0] { UiPageEntry::Inline(page) => { assert_eq!(page.id, "overview"); assert_eq!(page.route, "/plugins/roundtrip/overview"); // UiPage::validate must also succeed for inline pages page .validate() .expect("inline page must pass UiPage::validate"); }, UiPageEntry::File { .. } => { panic!("expected inline page entry, got file reference"); }, } } // warnings by PluginManifest::validate but do NOT cause an error return. // (The UiSection::validate failure is non-fatal; see manifest.rs.) #[test] fn test_manifest_bad_required_endpoint_is_non_fatal() { use pinakes_plugin_api::PluginManifest; let toml = r#" [plugin] name = "ep-test" version = "1.0.0" api_version = "1.0" kind = ["ui_page"] [plugin.binary] wasm = "plugin.wasm" [ui] required_endpoints = ["/not-an-api-path"] "#; // PluginManifest::validate emits a tracing::warn for invalid // required_endpoints but does not return Err - verify the manifest still // parses successfully. let result = PluginManifest::parse_str(toml); assert!( result.is_ok(), "bad required_endpoint should be non-fatal for PluginManifest::validate" ); }