Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I0de4c3e1e5b49579ae42983f93a2332e6a6a6964
361 lines
9.4 KiB
Rust
361 lines
9.4 KiB
Rust
//! 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"
|
|
);
|
|
}
|