1499 lines
55 KiB
Rust
1499 lines
55 KiB
Rust
//! Tests for the web-canvas daemon (`web_canvas_server.rs`) — sibling
|
|
//! file per the 800-line-cap convention (like `chat_session_tests.rs`).
|
|
|
|
use super::*;
|
|
|
|
fn fresh_state() -> WebCanvasState {
|
|
WebCanvasState::new(EditorState::new(), 3100)
|
|
}
|
|
|
|
fn fresh_server_persistence_state() -> WebCanvasState {
|
|
WebCanvasState::new_with_policy(
|
|
EditorState::new(),
|
|
3100,
|
|
crate::web_credential_policy::WebCredentialPersistence::Server,
|
|
)
|
|
}
|
|
|
|
// A minimal canonical document body in the TS `setSyncDocument` shape.
|
|
const SYNC_BODY: &str = r##"{"document":{"version":"1.0.0","children":[{"id":"n9","type":"rectangle","name":"Synced Rect","x":1,"y":2,"width":80,"height":40,"fill":[{"type":"solid","color":"#123456"}]}]},"sourceClientId":"web"}"##;
|
|
|
|
const CREDENTIAL_BODY: &str = r#"{
|
|
"version":2,
|
|
"builtin_agents":[{
|
|
"id":"builtin-web-1","preset":"custom","display_name":"Private Model",
|
|
"kind":"openai-compat","api_key":"sk-browser-only","model":"private-model",
|
|
"base_url":"https://api.openai.com/v1","enabled":true
|
|
}],
|
|
"image_gen_profiles":[{
|
|
"id":"igp-web-1","name":"Image","provider":"openai",
|
|
"api_key":"image-browser-only","model":"gpt-image-1","base_url":null
|
|
}],
|
|
"active_image_gen_profile_id":"igp-web-1",
|
|
"openverse_oauth":{"client_id":"client","client_secret":"openverse-browser-only"}
|
|
}"#;
|
|
|
|
fn write_temp_op(name: &str, body: &str) -> PathBuf {
|
|
let path = std::env::temp_dir().join(temp_op_file_name(
|
|
name,
|
|
std::thread::current().name().unwrap_or("test"),
|
|
));
|
|
std::fs::write(&path, body).expect("write temp op");
|
|
path
|
|
}
|
|
|
|
fn temp_op_file_name(name: &str, thread_name: &str) -> String {
|
|
format!(
|
|
"openpencil-web-canvas-{}-{}-{}.op",
|
|
sanitize_temp_file_component(name),
|
|
std::process::id(),
|
|
sanitize_temp_file_component(thread_name)
|
|
)
|
|
}
|
|
|
|
fn sanitize_temp_file_component(value: &str) -> String {
|
|
let sanitized: String = value
|
|
.chars()
|
|
.map(|ch| match ch {
|
|
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => ch,
|
|
_ => '-',
|
|
})
|
|
.collect();
|
|
let trimmed = sanitized.trim_matches(['-', '.']);
|
|
if trimmed.is_empty() {
|
|
"test".to_string()
|
|
} else {
|
|
trimmed.to_string()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn temp_op_file_name_sanitizes_windows_reserved_path_chars() {
|
|
let file_name = temp_op_file_name(r#"recent:ok"#, r#"web_canvas_server::tests\case?*"#);
|
|
|
|
for reserved in ['<', '>', ':', '"', '/', '\\', '|', '?', '*'] {
|
|
assert!(
|
|
!file_name.contains(reserved),
|
|
"{file_name} contains reserved path character {reserved:?}"
|
|
);
|
|
}
|
|
assert!(file_name.ends_with(".op"));
|
|
}
|
|
|
|
#[test]
|
|
fn server_health_matches_ts_running_port_shape() {
|
|
let r = handle_web_canvas_request("GET", "/api/mcp/server", "", &mut fresh_state());
|
|
assert!(r.status.starts_with("200"));
|
|
// TS `server.get.ts` parity: clients test `running` + `port`.
|
|
assert!(r.body.contains(r#""running":true"#));
|
|
assert!(r.body.contains(r#""port":3100"#));
|
|
assert!(r.body.contains(r#""localIp":"#));
|
|
assert!(r.body.contains(r#""server":"openpencil-mcp""#));
|
|
}
|
|
|
|
#[test]
|
|
fn credential_policy_endpoint_reports_only_the_boolean() {
|
|
let mut state = WebCanvasState::new_with_policy(
|
|
EditorState::new(),
|
|
3100,
|
|
crate::web_credential_policy::WebCredentialPersistence::BrowserOnly,
|
|
);
|
|
let reply = handle_web_canvas_request("GET", "/api/settings/credential-policy", "", &mut state);
|
|
|
|
assert_eq!(reply.status, "200 OK");
|
|
assert_eq!(reply.body, r#"{"serverPersistence":false}"#);
|
|
assert!(!reply.body.contains("api_key"));
|
|
assert!(!reply.body.contains("secret"));
|
|
}
|
|
|
|
#[test]
|
|
fn browser_only_route_rejects_credentials_without_mutating_state() {
|
|
let mut state = fresh_state();
|
|
let before = crate::settings_io::fingerprint(&state.editor);
|
|
|
|
let reply = handle_web_canvas_request(
|
|
"POST",
|
|
"/api/settings/credentials",
|
|
CREDENTIAL_BODY,
|
|
&mut state,
|
|
);
|
|
|
|
assert_eq!(reply.status, "403 Forbidden");
|
|
assert_eq!(before, crate::settings_io::fingerprint(&state.editor));
|
|
assert!(!reply.body.contains("sk-browser-only"));
|
|
}
|
|
|
|
#[test]
|
|
fn server_policy_merges_credentials_without_echoing_them() {
|
|
let mut state = fresh_server_persistence_state();
|
|
|
|
let reply = handle_web_canvas_request(
|
|
"POST",
|
|
"/api/settings/credentials",
|
|
CREDENTIAL_BODY,
|
|
&mut state,
|
|
);
|
|
|
|
assert_eq!(reply.status, "200 OK");
|
|
assert_eq!(reply.body, r#"{"ok":true}"#);
|
|
let agent = state
|
|
.editor
|
|
.editor_ui
|
|
.agent_settings
|
|
.builtin_agents
|
|
.iter()
|
|
.find(|agent| agent.id.ends_with(":builtin:builtin-web-1"))
|
|
.expect("merged browser agent");
|
|
assert_eq!(agent.api_key, "sk-browser-only");
|
|
assert!(!reply.body.contains("browser-only"));
|
|
}
|
|
|
|
#[test]
|
|
fn browser_only_restart_does_not_load_previously_persisted_browser_credentials() {
|
|
let mut persisted = EditorState::new();
|
|
crate::web_credentials::apply_json(&mut persisted, CREDENTIAL_BODY)
|
|
.expect("private deployment snapshot");
|
|
persisted.editor_ui.agent_settings.add_builtin_agent_config(
|
|
"Operator",
|
|
"operator-key",
|
|
"operator-model",
|
|
op_editor_core::BuiltinAgentKind::OpenAiCompat,
|
|
"https://operator.example/v1",
|
|
);
|
|
|
|
let state = WebCanvasState::new_with_policy(
|
|
persisted,
|
|
3100,
|
|
crate::web_credential_policy::WebCredentialPersistence::BrowserOnly,
|
|
);
|
|
let settings = &state.editor.editor_ui.agent_settings;
|
|
|
|
assert!(settings
|
|
.builtin_agents
|
|
.iter()
|
|
.all(|agent| !agent.id.starts_with("web-credential:")));
|
|
assert!(settings
|
|
.builtin_agents
|
|
.iter()
|
|
.any(|agent| agent.model == "operator-model"));
|
|
assert!(settings.image_gen_profiles.is_empty());
|
|
assert!(settings.openverse_client_secret.is_empty());
|
|
assert_eq!(settings.openverse_credential_owner, None);
|
|
}
|
|
|
|
#[test]
|
|
fn browser_only_startup_removes_browser_credentials_and_saves_once() {
|
|
let mut editor = EditorState::new();
|
|
crate::web_credentials::apply_json(&mut editor, CREDENTIAL_BODY)
|
|
.expect("private deployment snapshot");
|
|
editor.editor_ui.agent_settings.add_builtin_agent_config(
|
|
"Operator",
|
|
"operator-key",
|
|
"operator-model",
|
|
op_editor_core::BuiltinAgentKind::OpenAiCompat,
|
|
"https://operator.example/v1",
|
|
);
|
|
let mut saves = 0;
|
|
|
|
enforce_credential_persistence_policy(
|
|
&mut editor,
|
|
crate::web_credential_policy::WebCredentialPersistence::BrowserOnly,
|
|
|saved| {
|
|
saves += 1;
|
|
let settings = &saved.editor_ui.agent_settings;
|
|
assert!(settings
|
|
.builtin_agents
|
|
.iter()
|
|
.all(|agent| !agent.id.starts_with("web-credential:")));
|
|
assert!(settings
|
|
.builtin_agents
|
|
.iter()
|
|
.any(|agent| agent.model == "operator-model"));
|
|
assert!(settings.image_gen_profiles.is_empty());
|
|
assert!(settings.openverse_client_secret.is_empty());
|
|
Ok(())
|
|
},
|
|
)
|
|
.expect("browser credentials are scrubbed and saved");
|
|
|
|
assert_eq!(saves, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn browser_only_startup_propagates_credential_scrub_save_failure() {
|
|
let mut editor = EditorState::new();
|
|
crate::web_credentials::apply_json(&mut editor, CREDENTIAL_BODY)
|
|
.expect("private deployment snapshot");
|
|
|
|
let error = enforce_credential_persistence_policy(
|
|
&mut editor,
|
|
crate::web_credential_policy::WebCredentialPersistence::BrowserOnly,
|
|
|_| Err("simulated disk failure".into()),
|
|
)
|
|
.expect_err("startup must fail when scrubbed settings cannot be saved");
|
|
|
|
assert_eq!(
|
|
error,
|
|
"failed to remove browser-owned credentials while server persistence is disabled"
|
|
);
|
|
assert!(editor
|
|
.editor_ui
|
|
.agent_settings
|
|
.builtin_agents
|
|
.iter()
|
|
.all(|agent| !agent.id.starts_with("web-credential:")));
|
|
}
|
|
|
|
#[test]
|
|
fn server_persistence_startup_keeps_browser_credentials_without_saving() {
|
|
let mut editor = EditorState::new();
|
|
crate::web_credentials::apply_json(&mut editor, CREDENTIAL_BODY)
|
|
.expect("private deployment snapshot");
|
|
|
|
enforce_credential_persistence_policy(
|
|
&mut editor,
|
|
crate::web_credential_policy::WebCredentialPersistence::Server,
|
|
|_| panic!("server persistence must not rewrite settings at startup"),
|
|
)
|
|
.expect("server persistence keeps stored browser credentials");
|
|
|
|
assert!(editor
|
|
.editor_ui
|
|
.agent_settings
|
|
.builtin_agents
|
|
.iter()
|
|
.any(|agent| agent.id.starts_with("web-credential:")));
|
|
}
|
|
|
|
#[test]
|
|
fn credential_persistence_failure_rolls_back_and_returns_500_without_echoing_secrets() {
|
|
let mut state = fresh_server_persistence_state();
|
|
let settings_before = crate::settings_io::fingerprint(&state.editor);
|
|
let agent_settings_before = state.editor.editor_ui.agent_settings.clone();
|
|
let document_children = state.editor.doc.children.as_ptr();
|
|
let reply = handle_web_canvas_request(
|
|
"POST",
|
|
"/api/settings/credentials",
|
|
CREDENTIAL_BODY,
|
|
&mut state,
|
|
);
|
|
assert_eq!(reply.status, "200 OK");
|
|
|
|
let reply = persist_api_settings(
|
|
"POST",
|
|
"/api/settings/credentials",
|
|
&mut state,
|
|
settings_before.clone(),
|
|
Some(agent_settings_before),
|
|
reply,
|
|
|_| Err("simulated disk failure".into()),
|
|
);
|
|
|
|
assert_eq!(reply.status, "500 Internal Server Error");
|
|
assert_eq!(
|
|
settings_before,
|
|
crate::settings_io::fingerprint(&state.editor)
|
|
);
|
|
assert_eq!(state.editor.doc.children.as_ptr(), document_children);
|
|
assert!(!reply.body.contains("sk-browser-only"));
|
|
assert!(!reply.body.contains("simulated disk failure"));
|
|
}
|
|
|
|
#[test]
|
|
fn cross_origin_browser_cannot_write_server_credentials() {
|
|
let state = Mutex::new(fresh_server_persistence_state());
|
|
let before = {
|
|
let guard = state.lock().unwrap();
|
|
crate::settings_io::fingerprint(&guard.editor)
|
|
};
|
|
let request = format!(
|
|
"POST /api/settings/credentials HTTP/1.1\r\nHost: 127.0.0.1:3100\r\nOrigin: https://evil.example\r\nContent-Length: {}\r\n\r\n{}",
|
|
CREDENTIAL_BODY.len(),
|
|
CREDENTIAL_BODY
|
|
);
|
|
let request_len = request.len();
|
|
let mut stream = std::io::Cursor::new(request.into_bytes());
|
|
|
|
serve_one(&mut stream, &state, &SseHub::default()).expect("request handled");
|
|
|
|
let response = String::from_utf8_lossy(&stream.get_ref()[request_len..]);
|
|
assert!(response.contains("403 Forbidden"));
|
|
assert!(response.contains("cross-origin"));
|
|
assert!(!response.contains("sk-browser-only"));
|
|
let guard = state.lock().unwrap();
|
|
assert_eq!(before, crate::settings_io::fingerprint(&guard.editor));
|
|
}
|
|
|
|
#[test]
|
|
fn credential_origin_check_allows_default_loopback_and_non_browser_clients() {
|
|
for headers in [
|
|
"Host: 127.0.0.1:3100\r\nOrigin: http://127.0.0.1:3100\r\n",
|
|
"Host: localhost:3100\r\nOrigin: http://localhost:3100\r\n",
|
|
"Host: [::1]:3100\r\nOrigin: http://[::1]:3100\r\n",
|
|
"Host: private.example:8443\r\n",
|
|
] {
|
|
let request = format!(
|
|
"POST /api/settings/credentials HTTP/1.1\r\n{headers}Content-Length: 0\r\n\r\n"
|
|
);
|
|
let mut stream = std::io::Cursor::new(request.into_bytes());
|
|
let request = crate::mcp_serve::read_http_request(&mut stream).unwrap();
|
|
assert!(
|
|
credential_request_origin_allowed_with_config(&request, None),
|
|
"headers={headers:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn credential_origin_check_allows_an_explicitly_configured_public_origin() {
|
|
let request = "POST /api/settings/credentials HTTP/1.1\r\nHost: demo.example:8443\r\nOrigin: https://demo.example:8443\r\nContent-Length: 0\r\n\r\n";
|
|
let mut stream = std::io::Cursor::new(request.as_bytes());
|
|
let request = crate::mcp_serve::read_http_request(&mut stream).unwrap();
|
|
|
|
assert!(credential_request_origin_allowed_with_config(
|
|
&request,
|
|
Some("https://other.example, https://demo.example:8443"),
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn credential_origin_check_rejects_an_unconfigured_public_same_host_origin() {
|
|
let request = "POST /api/settings/credentials HTTP/1.1\r\nHost: evil.example\r\nOrigin: https://evil.example\r\nContent-Length: 0\r\n\r\n";
|
|
let mut stream = std::io::Cursor::new(request.as_bytes());
|
|
let request = crate::mcp_serve::read_http_request(&mut stream).unwrap();
|
|
|
|
assert!(!credential_request_origin_allowed_with_config(
|
|
&request, None,
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn credential_origin_check_rejects_null_malformed_and_cross_authority_origins() {
|
|
for origin in [
|
|
"null",
|
|
"://bad",
|
|
"https://evil.example",
|
|
"file://private.example",
|
|
] {
|
|
let request = format!(
|
|
"POST /api/settings/credentials HTTP/1.1\r\nHost: private.example\r\nOrigin: {origin}\r\nContent-Length: 0\r\n\r\n"
|
|
);
|
|
let mut stream = std::io::Cursor::new(request.into_bytes());
|
|
let request = crate::mcp_serve::read_http_request(&mut stream).unwrap();
|
|
assert!(
|
|
!credential_request_origin_allowed_with_config(&request, None),
|
|
"origin={origin}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sensitive_browser_posts_include_credentials_and_ai_routes() {
|
|
for path in ["/api/settings/credentials", "/api/ai/stream"] {
|
|
let request = crate::mcp_serve::HttpRequest {
|
|
method: "POST".into(),
|
|
path: path.into(),
|
|
body: String::new(),
|
|
host: None,
|
|
origin: None,
|
|
token: None,
|
|
};
|
|
assert!(is_sensitive_browser_post(&request), "path={path}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn post_mcp_server_start_stop_updates_daemon_agent_settings() {
|
|
let mut s = fresh_state();
|
|
assert!(!s.editor.editor_ui.agent_settings.mcp_server.running);
|
|
|
|
let start = handle_web_canvas_request(
|
|
"POST",
|
|
"/api/mcp/server",
|
|
r#"{"action":"start","port":3201}"#,
|
|
&mut s,
|
|
);
|
|
|
|
assert!(start.status.starts_with("200"), "{}", start.body);
|
|
assert!(start.body.contains(r#""ok":true"#), "{}", start.body);
|
|
assert!(s.editor.editor_ui.agent_settings.mcp_server.running);
|
|
assert_eq!(s.editor.editor_ui.agent_settings.mcp_server.port, 3201);
|
|
assert_eq!(s.version, 0, "MCP settings are not document mutations");
|
|
|
|
let stop = handle_web_canvas_request(
|
|
"POST",
|
|
"/api/mcp/server",
|
|
r#"{"action":"stop","port":3201}"#,
|
|
&mut s,
|
|
);
|
|
|
|
assert!(stop.status.starts_with("200"), "{}", stop.body);
|
|
assert!(!s.editor.editor_ui.agent_settings.mcp_server.running);
|
|
assert_eq!(s.editor.editor_ui.agent_settings.mcp_server.port, 3201);
|
|
assert_eq!(s.version, 0, "MCP settings are not document mutations");
|
|
}
|
|
|
|
#[test]
|
|
fn post_mcp_server_rejects_invalid_body_without_changing_settings() {
|
|
let mut s = fresh_state();
|
|
s.editor.editor_ui.agent_settings.mcp_server.running = true;
|
|
s.editor.editor_ui.agent_settings.mcp_server.port = 4321;
|
|
|
|
let r = handle_web_canvas_request("POST", "/api/mcp/server", r#"{"action":"restart"}"#, &mut s);
|
|
|
|
assert!(r.status.starts_with("400"), "{}", r.body);
|
|
assert!(r.body.contains("Invalid MCP server action"), "{}", r.body);
|
|
assert!(s.editor.editor_ui.agent_settings.mcp_server.running);
|
|
assert_eq!(s.editor.editor_ui.agent_settings.mcp_server.port, 4321);
|
|
}
|
|
|
|
#[test]
|
|
fn get_document_returns_doc_and_version() {
|
|
let r = handle_web_canvas_request("GET", "/api/mcp/document", "", &mut fresh_state());
|
|
assert!(r.status.starts_with("200"));
|
|
assert!(r.body.contains(r#""document":"#));
|
|
assert!(r.body.contains(r#""version":0"#));
|
|
}
|
|
|
|
#[test]
|
|
fn no_path_startup_uses_the_same_starter_document_as_the_web_shell() {
|
|
let editor = startup_editor_for_web_canvas(None).expect("startup editor");
|
|
assert_eq!(editor.doc, EditorState::starter().doc);
|
|
}
|
|
|
|
#[test]
|
|
fn every_web_persistence_policy_propagates_strict_settings_load_failures() {
|
|
for policy in [
|
|
crate::web_credential_policy::WebCredentialPersistence::BrowserOnly,
|
|
crate::web_credential_policy::WebCredentialPersistence::Server,
|
|
] {
|
|
let checked_calls = std::cell::Cell::new(0);
|
|
let result = startup_editor_for_web_canvas_with_loader(None, policy, |_| {
|
|
checked_calls.set(checked_calls.get() + 1);
|
|
Err("invalid existing settings".into())
|
|
});
|
|
|
|
let error = result.expect_err("all web policies must fail closed on invalid settings");
|
|
assert_eq!(error, "invalid existing settings");
|
|
assert_eq!(checked_calls.get(), 1, "policy={policy:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn post_document_replaces_doc_and_bumps_version() {
|
|
use op_editor_core::PenNodeExt;
|
|
let mut s = fresh_state();
|
|
let r = handle_web_canvas_request("POST", "/api/mcp/document", SYNC_BODY, &mut s);
|
|
assert!(r.status.starts_with("200"), "{}", r.body);
|
|
assert!(r.body.contains(r#""ok":true"#));
|
|
assert!(r.body.contains(r#""version":1"#));
|
|
// The in-memory document was replaced with the synced tree.
|
|
assert!(s
|
|
.editor
|
|
.active_children()
|
|
.iter()
|
|
.any(|n| n.base().name.as_deref() == Some("Synced Rect")));
|
|
// A second sync bumps the version again (monotonic).
|
|
let r2 = handle_web_canvas_request("POST", "/api/mcp/document", SYNC_BODY, &mut s);
|
|
assert!(r2.body.contains(r#""version":2"#));
|
|
}
|
|
|
|
#[test]
|
|
fn post_file_save_requires_a_known_daemon_path() {
|
|
let mut s = fresh_state();
|
|
|
|
let r = handle_web_canvas_request("POST", "/api/file/save", SYNC_BODY, &mut s);
|
|
|
|
assert!(r.status.starts_with("400"), "{}", r.body);
|
|
assert!(r.body.contains("No file path"), "{}", r.body);
|
|
assert_eq!(s.version, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn post_file_save_writes_current_path_and_embedded_active_page_meta() {
|
|
use op_editor_core::PenNodeExt;
|
|
|
|
let path = write_temp_op("save-target", r#"{"version":"1.0.0","children":[]}"#);
|
|
let mut s = WebCanvasState::new_with_path(EditorState::new(), 3100, Some(path.clone()));
|
|
let body = r##"{"document":{"version":"1.0.0","children":[],"pages":[{"id":"p1","name":"One","children":[]},{"id":"p2","name":"Two","children":[{"id":"saved-node","type":"rectangle","name":"Saved Rect","x":1,"y":2,"width":80,"height":40,"fill":[{"type":"solid","color":"#123456"}]}]}]},"activePageIndex":1}"##;
|
|
|
|
let r = handle_web_canvas_request("POST", "/api/file/save", body, &mut s);
|
|
|
|
assert!(r.status.starts_with("200"), "{}", r.body);
|
|
assert!(r.body.contains(r#""ok":true"#), "{}", r.body);
|
|
assert_eq!(s.version, 1);
|
|
assert_eq!(s.editor.ui.active_page_index, 1);
|
|
assert_eq!(s.editor.active_children()[0].base().id, "saved-node");
|
|
let saved = std::fs::read_to_string(&path).expect("saved file");
|
|
assert!(saved.contains("saved-node"), "{saved}");
|
|
let saved_json: serde_json::Value = serde_json::from_str(&saved).expect("saved json");
|
|
assert_eq!(saved_json["editorMeta"]["activePageIndex"], 1);
|
|
let mut sidecar = path.clone();
|
|
sidecar.set_extension("op.opmeta");
|
|
assert!(
|
|
!sidecar.exists(),
|
|
"new saves should keep active page metadata inside the .op file"
|
|
);
|
|
let _ = std::fs::remove_file(path);
|
|
let _ = std::fs::remove_file(sidecar);
|
|
}
|
|
|
|
#[test]
|
|
fn sync_reset_reloads_current_path_when_daemon_has_backing_file() {
|
|
use op_editor_core::PenNodeExt;
|
|
|
|
let path = write_temp_op(
|
|
"reset-backed",
|
|
r#"{"version":"1.0.0","children":[{"id":"from-disk","type":"rectangle","name":"Disk Rect","x":1,"y":2,"width":80,"height":40}]}"#,
|
|
);
|
|
let mut s = WebCanvasState::new_with_path(EditorState::starter(), 3100, Some(path.clone()));
|
|
let _ = s.replace_document(
|
|
op_pen_loader::load_canonical(
|
|
r#"{"version":"1.0.0","children":[{"id":"transient","type":"rectangle","name":"Transient","x":1,"y":2,"width":80,"height":40}]}"#,
|
|
)
|
|
.expect("transient doc")
|
|
.value,
|
|
);
|
|
|
|
let r = handle_web_canvas_request("POST", "/api/mcp/sync-reset", "", &mut s);
|
|
|
|
assert!(r.status.starts_with("200"), "{}", r.body);
|
|
assert_eq!(s.version, 2);
|
|
assert_eq!(s.editor.active_children()[0].base().id, "from-disk");
|
|
assert_eq!(
|
|
s.editor.editor_ui.file_name_display.as_deref(),
|
|
Some(path.file_name().unwrap().to_str().unwrap())
|
|
);
|
|
assert_eq!(s.current_path.as_deref(), Some(path.as_path()));
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn post_document_rejects_invalid_body_with_400() {
|
|
let mut s = fresh_state();
|
|
let r = handle_web_canvas_request("POST", "/api/mcp/document", r#"{"nope":1}"#, &mut s);
|
|
assert!(r.status.starts_with("400"));
|
|
assert!(r.body.contains("Missing document in request body"));
|
|
// A rejected sync must not bump the version.
|
|
assert_eq!(s.version, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_route_404s() {
|
|
let r = handle_web_canvas_request("DELETE", "/whatever", "", &mut fresh_state());
|
|
assert!(r.status.starts_with("404"));
|
|
}
|
|
|
|
#[test]
|
|
fn get_ai_models_returns_json_array() {
|
|
let mut state = fresh_state();
|
|
state
|
|
.editor
|
|
.editor_ui
|
|
.agent_settings
|
|
.add_builtin_agent_with_defaults("Built-in", "sk-test", "built-in-model");
|
|
state.editor.chat.discovered_models = vec![op_editor_core::ModelEntry::new(
|
|
op_editor_core::AgentProvider::ClaudeCode,
|
|
"cli-model",
|
|
"CLI model",
|
|
)];
|
|
state
|
|
.editor
|
|
.editor_ui
|
|
.agent_settings
|
|
.apply_provider_connect_outcome(
|
|
op_editor_core::AgentProvider::ClaudeCode,
|
|
op_editor_core::ProviderConnectOutcome {
|
|
connected: true,
|
|
info: Some("Connected via CLI".into()),
|
|
..Default::default()
|
|
},
|
|
);
|
|
state.editor.rebuild_chat_models();
|
|
|
|
let r = handle_web_canvas_request("GET", "/api/ai/models", "", &mut state);
|
|
assert!(r.status.starts_with("200"));
|
|
assert_eq!(
|
|
serde_json::from_str::<Vec<String>>(&r.body).expect("models body is valid JSON"),
|
|
vec!["built-in-model"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn post_export_pdf_returns_base64_pdf_without_replacing_daemon_document() {
|
|
use base64::Engine as _;
|
|
use op_editor_core::PenNodeExt;
|
|
|
|
let mut s = fresh_state();
|
|
let before_names: Vec<_> = s
|
|
.editor
|
|
.active_children()
|
|
.iter()
|
|
.filter_map(|n| n.base().name.clone())
|
|
.collect();
|
|
let export_body = r##"{"document":{"version":"1.0.0","children":[{"id":"pdf-node","type":"rectangle","name":"PDF Rect","x":1,"y":2,"width":80,"height":40,"fill":[{"type":"solid","color":"#123456"}]}]}}"##;
|
|
|
|
let r = handle_web_canvas_request("POST", "/api/export/pdf", export_body, &mut s);
|
|
|
|
assert!(r.status.starts_with("200"), "{}", r.body);
|
|
let parsed: serde_json::Value = serde_json::from_str(&r.body).expect("json body");
|
|
assert_eq!(parsed["ok"], true);
|
|
assert_eq!(parsed["mime"], "application/pdf");
|
|
assert_eq!(parsed["fileName"], "openpencil-export.pdf");
|
|
let data = parsed["dataBase64"].as_str().expect("dataBase64 string");
|
|
let pdf = base64::engine::general_purpose::STANDARD
|
|
.decode(data)
|
|
.expect("base64 pdf");
|
|
assert!(pdf.starts_with(b"%PDF-"), "missing PDF header");
|
|
assert!(
|
|
pdf.windows(b"%%EOF".len()).any(|w| w == b"%%EOF"),
|
|
"missing PDF EOF"
|
|
);
|
|
|
|
assert_eq!(s.version, 0, "export must not mutate sync version");
|
|
let after_names: Vec<_> = s
|
|
.editor
|
|
.active_children()
|
|
.iter()
|
|
.filter_map(|n| n.base().name.clone())
|
|
.collect();
|
|
assert_eq!(after_names, before_names);
|
|
}
|
|
|
|
#[test]
|
|
fn post_export_pdf_rejects_invalid_document_without_mutating_state() {
|
|
let mut s = fresh_state();
|
|
|
|
let r = handle_web_canvas_request("POST", "/api/export/pdf", r#"{"document":1}"#, &mut s);
|
|
|
|
assert!(r.status.starts_with("400"), "{}", r.body);
|
|
assert!(r.body.contains("export PDF"), "{}", r.body);
|
|
assert_eq!(s.version, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn post_export_pdf_uses_request_active_page_index() {
|
|
use base64::Engine as _;
|
|
|
|
let mut s = fresh_state();
|
|
let export_body = r##"{"activePageIndex":1,"document":{"version":"1.0.0","children":[],"pages":[{"id":"p1","name":"Empty","children":[]},{"id":"p2","name":"Exported","children":[{"id":"pdf-page-two","type":"rectangle","name":"PDF Page Two","x":1,"y":2,"width":80,"height":40,"fill":[{"type":"solid","color":"#123456"}]}]}]}}"##;
|
|
|
|
let r = handle_web_canvas_request("POST", "/api/export/pdf", export_body, &mut s);
|
|
|
|
assert!(r.status.starts_with("200"), "{}", r.body);
|
|
let parsed: serde_json::Value = serde_json::from_str(&r.body).expect("json body");
|
|
let data = parsed["dataBase64"].as_str().expect("dataBase64 string");
|
|
let pdf = base64::engine::general_purpose::STANDARD
|
|
.decode(data)
|
|
.expect("base64 pdf");
|
|
assert!(pdf.starts_with(b"%PDF-"), "missing PDF header");
|
|
assert_eq!(s.version, 0, "export must not mutate sync version");
|
|
}
|
|
|
|
#[test]
|
|
fn post_export_raster_returns_base64_png_without_replacing_daemon_document() {
|
|
use base64::Engine as _;
|
|
use op_editor_core::PenNodeExt;
|
|
|
|
let mut s = fresh_state();
|
|
let before_names: Vec<_> = s
|
|
.editor
|
|
.active_children()
|
|
.iter()
|
|
.filter_map(|n| n.base().name.clone())
|
|
.collect();
|
|
let export_body = r##"{"format":"png","scale":1,"document":{"version":"1.0.0","children":[{"id":"png-node","type":"rectangle","name":"PNG Rect","x":1,"y":2,"width":80,"height":40,"fill":[{"type":"solid","color":"#123456"}]}]}}"##;
|
|
|
|
let r = handle_web_canvas_request("POST", "/api/export/raster", export_body, &mut s);
|
|
|
|
assert!(r.status.starts_with("200"), "{}", r.body);
|
|
let parsed: serde_json::Value = serde_json::from_str(&r.body).expect("json body");
|
|
assert_eq!(parsed["ok"], true);
|
|
assert_eq!(parsed["mime"], "image/png");
|
|
assert_eq!(parsed["fileName"], "openpencil-export.png");
|
|
let data = parsed["dataBase64"].as_str().expect("dataBase64 string");
|
|
let png = base64::engine::general_purpose::STANDARD
|
|
.decode(data)
|
|
.expect("base64 png");
|
|
assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"), "missing PNG header");
|
|
|
|
assert_eq!(s.version, 0, "export must not mutate sync version");
|
|
let after_names: Vec<_> = s
|
|
.editor
|
|
.active_children()
|
|
.iter()
|
|
.filter_map(|n| n.base().name.clone())
|
|
.collect();
|
|
assert_eq!(after_names, before_names);
|
|
}
|
|
|
|
#[test]
|
|
fn post_export_raster_crops_to_selected_node() {
|
|
use base64::Engine as _;
|
|
|
|
let mut s = fresh_state();
|
|
let export_body = r##"{"format":"png","scale":1,"selectedNodeId":"small","document":{"version":"1.0.0","children":[{"id":"small","type":"rectangle","name":"Small","x":0,"y":0,"width":10,"height":10,"fill":[{"type":"solid","color":"#123456"}]},{"id":"far","type":"rectangle","name":"Far","x":300,"y":0,"width":50,"height":50,"fill":[{"type":"solid","color":"#654321"}]}]}}"##;
|
|
|
|
let r = handle_web_canvas_request("POST", "/api/export/raster", export_body, &mut s);
|
|
|
|
assert!(r.status.starts_with("200"), "{}", r.body);
|
|
let parsed: serde_json::Value = serde_json::from_str(&r.body).expect("json body");
|
|
let data = parsed["dataBase64"].as_str().expect("dataBase64 string");
|
|
let png = base64::engine::general_purpose::STANDARD
|
|
.decode(data)
|
|
.expect("base64 png");
|
|
|
|
assert_eq!(png_dimensions(&png), (10, 10));
|
|
assert_eq!(s.version, 0, "export must not mutate sync version");
|
|
}
|
|
|
|
#[test]
|
|
fn post_export_raster_uses_request_active_page_index() {
|
|
use base64::Engine as _;
|
|
|
|
let mut s = fresh_state();
|
|
let export_body = r##"{"format":"png","scale":1,"activePageIndex":1,"selectedNodeId":"page-two","document":{"version":"1.0.0","children":[],"pages":[{"id":"p1","name":"Empty","children":[]},{"id":"p2","name":"Exported","children":[{"id":"page-two","type":"rectangle","name":"Page Two","x":0,"y":0,"width":10,"height":10,"fill":[{"type":"solid","color":"#123456"}]}]}]}}"##;
|
|
|
|
let r = handle_web_canvas_request("POST", "/api/export/raster", export_body, &mut s);
|
|
|
|
assert!(r.status.starts_with("200"), "{}", r.body);
|
|
let parsed: serde_json::Value = serde_json::from_str(&r.body).expect("json body");
|
|
let data = parsed["dataBase64"].as_str().expect("dataBase64 string");
|
|
let png = base64::engine::general_purpose::STANDARD
|
|
.decode(data)
|
|
.expect("base64 png");
|
|
assert_eq!(png_dimensions(&png), (10, 10));
|
|
assert_eq!(s.version, 0, "export must not mutate sync version");
|
|
}
|
|
|
|
fn png_dimensions(bytes: &[u8]) -> (u32, u32) {
|
|
assert!(
|
|
bytes.starts_with(b"\x89PNG\r\n\x1a\n"),
|
|
"missing PNG header"
|
|
);
|
|
let width = u32::from_be_bytes(bytes[16..20].try_into().expect("png width"));
|
|
let height = u32::from_be_bytes(bytes[20..24].try_into().expect("png height"));
|
|
(width, height)
|
|
}
|
|
|
|
#[test]
|
|
fn web_cli_and_acp_connect_routes_are_unavailable_in_both_dispatchers() {
|
|
for path in ["/api/agents/connect", "/api/acp/connect"] {
|
|
let direct = handle_web_canvas_request("POST", path, "{}", &mut fresh_state());
|
|
assert_eq!(direct.status, "404 Not Found", "path={path}");
|
|
|
|
let response = serve("POST", path, "{}");
|
|
assert!(
|
|
response.contains("404 Not Found"),
|
|
"path={path}, {response}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn post_open_recent_loads_recent_path_and_bumps_version() {
|
|
use op_editor_core::editor_ui_state::RecentFile;
|
|
use op_editor_core::PenNodeExt;
|
|
|
|
let path = write_temp_op(
|
|
"recent-ok",
|
|
r##"{"version":"1.0.0","children":[{"id":"recent-node","type":"rectangle","name":"Opened Recent","x":3,"y":4,"width":20,"height":10}]}"##,
|
|
);
|
|
let mut s = fresh_state();
|
|
s.editor.editor_ui.recent_files = vec![RecentFile {
|
|
path: path.to_string_lossy().into_owned(),
|
|
modified_at: 1,
|
|
}];
|
|
|
|
let body = serde_json::json!({ "path": path.to_string_lossy() }).to_string();
|
|
let r = handle_web_canvas_request("POST", "/api/file/open-recent", &body, &mut s);
|
|
|
|
assert!(r.status.starts_with("200"), "{}", r.body);
|
|
assert!(r.body.contains(r#""ok":true"#), "{}", r.body);
|
|
assert_eq!(s.version, 1);
|
|
assert!(s
|
|
.editor
|
|
.active_children()
|
|
.iter()
|
|
.any(|n| n.base().name.as_deref() == Some("Opened Recent")));
|
|
assert_eq!(
|
|
s.editor.editor_ui.recent_files[0].path,
|
|
path.to_string_lossy()
|
|
);
|
|
assert_eq!(
|
|
s.editor.editor_ui.file_name_display.as_deref(),
|
|
Some(path.file_name().unwrap().to_str().unwrap())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn post_open_recent_prunes_stale_recent_path_without_replacing_doc() {
|
|
use op_editor_core::editor_ui_state::RecentFile;
|
|
use op_editor_core::PenNodeExt;
|
|
|
|
let missing = std::env::temp_dir().join(format!(
|
|
"openpencil-web-canvas-missing-{}.op",
|
|
std::process::id()
|
|
));
|
|
let mut s = fresh_state();
|
|
s.editor.editor_ui.recent_files = vec![RecentFile {
|
|
path: missing.to_string_lossy().into_owned(),
|
|
modified_at: 1,
|
|
}];
|
|
let before_names: Vec<_> = s
|
|
.editor
|
|
.active_children()
|
|
.iter()
|
|
.filter_map(|n| n.base().name.clone())
|
|
.collect();
|
|
|
|
let body = serde_json::json!({ "path": missing.to_string_lossy() }).to_string();
|
|
let r = handle_web_canvas_request("POST", "/api/file/open-recent", &body, &mut s);
|
|
|
|
assert!(r.status.starts_with("400"), "{}", r.body);
|
|
assert!(r.body.contains(r#""pruned":true"#), "{}", r.body);
|
|
assert_eq!(s.version, 0);
|
|
assert!(s.editor.editor_ui.recent_files.is_empty());
|
|
let after_names: Vec<_> = s
|
|
.editor
|
|
.active_children()
|
|
.iter()
|
|
.filter_map(|n| n.base().name.clone())
|
|
.collect();
|
|
assert_eq!(after_names, before_names);
|
|
}
|
|
|
|
#[test]
|
|
fn get_version_is_a_cheap_change_probe() {
|
|
let mut s = fresh_state();
|
|
let r = handle_web_canvas_request("GET", "/api/mcp/version", "", &mut s);
|
|
assert!(r.status.starts_with("200"));
|
|
assert_eq!(r.body, r#"{"version":0}"#);
|
|
// A document mutation bumps the probed version.
|
|
let _ = handle_web_canvas_request("POST", "/api/mcp/document", SYNC_BODY, &mut s);
|
|
let r2 = handle_web_canvas_request("GET", "/api/mcp/version", "", &mut s);
|
|
assert_eq!(r2.body, r#"{"version":1}"#);
|
|
}
|
|
|
|
#[test]
|
|
fn selection_post_then_get_round_trips_ts_shape() {
|
|
let mut s = fresh_state();
|
|
// Initial GET: the TS `getSyncSelection()` empty shape.
|
|
let r = handle_web_canvas_request("GET", "/api/mcp/selection", "", &mut s);
|
|
assert!(r.status.starts_with("200"));
|
|
let v: serde_json::Value = serde_json::from_str(&r.body).expect("json");
|
|
assert_eq!(v["selectedIds"], serde_json::json!([]));
|
|
assert_eq!(v["activePageId"], serde_json::Value::Null);
|
|
// Renderer push (TS selection.post.ts body shape).
|
|
let post = handle_web_canvas_request(
|
|
"POST",
|
|
"/api/mcp/selection",
|
|
r#"{"selectedIds":["n1","n2"],"activePageId":null,"sourceClientId":"renderer:1"}"#,
|
|
&mut s,
|
|
);
|
|
assert!(post.status.starts_with("200"), "{}", post.body);
|
|
assert!(post.body.contains(r#""ok":true"#));
|
|
// Selection is NOT a document mutation — version must not bump.
|
|
assert_eq!(s.version, 0);
|
|
// GET reflects the push; the live editor selection agrees.
|
|
let r2 = handle_web_canvas_request("GET", "/api/mcp/selection", "", &mut s);
|
|
let v2: serde_json::Value = serde_json::from_str(&r2.body).expect("json");
|
|
assert_eq!(v2["selectedIds"], serde_json::json!(["n1", "n2"]));
|
|
assert_eq!(s.editor.selection.set.len(), 2);
|
|
assert_eq!(s.editor.selection.anchor.as_str(), "n2");
|
|
}
|
|
|
|
#[test]
|
|
fn selection_post_rejects_missing_ids_with_ts_error_text() {
|
|
let mut s = fresh_state();
|
|
for bad in [
|
|
r#"{"activePageId":"p1"}"#,
|
|
r#"{"selectedIds":"n1"}"#,
|
|
"nope",
|
|
] {
|
|
let r = handle_web_canvas_request("POST", "/api/mcp/selection", bad, &mut s);
|
|
assert!(r.status.starts_with("400"), "{bad} → {}", r.status);
|
|
assert!(r.body.contains("Missing selectedIds array"), "{}", r.body);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn selection_post_switches_the_active_page_when_the_id_resolves() {
|
|
let mut s = fresh_state();
|
|
let paged = r##"{"document":{"version":"1.0.0","children":[],"pages":[
|
|
{"id":"p1","name":"One","children":[]},
|
|
{"id":"p2","name":"Two","children":[]}
|
|
]}}"##;
|
|
let r = handle_web_canvas_request("POST", "/api/mcp/document", paged, &mut s);
|
|
assert!(r.status.starts_with("200"), "{}", r.body);
|
|
let post = handle_web_canvas_request(
|
|
"POST",
|
|
"/api/mcp/selection",
|
|
r#"{"selectedIds":[],"activePageId":"p2"}"#,
|
|
&mut s,
|
|
);
|
|
assert!(post.status.starts_with("200"));
|
|
assert_eq!(s.editor.ui.active_page_index, 1);
|
|
let get = handle_web_canvas_request("GET", "/api/mcp/selection", "", &mut s);
|
|
assert!(get.body.contains(r#""activePageId":"p2""#), "{}", get.body);
|
|
// An unknown page id is ignored (documented divergence from TS, which
|
|
// stores the raw string): the active page stays put.
|
|
let _ = handle_web_canvas_request(
|
|
"POST",
|
|
"/api/mcp/selection",
|
|
r#"{"selectedIds":[],"activePageId":"ghost"}"#,
|
|
&mut s,
|
|
);
|
|
assert_eq!(s.editor.ui.active_page_index, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn selection_push_is_visible_to_the_mcp_get_selection_tool() {
|
|
// The point of the selection sync: an external MCP client asking
|
|
// `get_selection` over `/mcp` must see what the browser pushed.
|
|
let mut s = fresh_state();
|
|
let seeded = handle_web_canvas_request("POST", "/api/mcp/document", SYNC_BODY, &mut s);
|
|
assert!(seeded.status.starts_with("200"), "{}", seeded.body);
|
|
let post = handle_web_canvas_request(
|
|
"POST",
|
|
"/api/mcp/selection",
|
|
r#"{"selectedIds":["n9"]}"#,
|
|
&mut s,
|
|
);
|
|
assert!(post.status.starts_with("200"));
|
|
// Dispatch get_selection through the same applier path serve_one uses.
|
|
let msg = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_selection","arguments":{}}}"#;
|
|
let response =
|
|
crate::mcp_serve::process_message_with_applier(&mut s.editor, msg, |editor, cmd| {
|
|
editor.apply(cmd.clone())
|
|
})
|
|
.expect("dispatch")
|
|
.unwrap_or_default();
|
|
assert!(response.contains("n9"), "{response}");
|
|
}
|
|
|
|
// --- serve_one routing (socket-level, via a mock stream) ---
|
|
|
|
struct MockStream {
|
|
input: std::io::Cursor<Vec<u8>>,
|
|
output: Vec<u8>,
|
|
}
|
|
|
|
#[cfg(feature = "mcp-debug-tools")]
|
|
struct EnvVarGuard {
|
|
key: &'static str,
|
|
previous: Option<std::ffi::OsString>,
|
|
}
|
|
|
|
#[cfg(feature = "mcp-debug-tools")]
|
|
impl EnvVarGuard {
|
|
fn set(key: &'static str, value: &str) -> Self {
|
|
let previous = std::env::var_os(key);
|
|
std::env::set_var(key, value);
|
|
Self { key, previous }
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "mcp-debug-tools")]
|
|
impl Drop for EnvVarGuard {
|
|
fn drop(&mut self) {
|
|
match &self.previous {
|
|
Some(value) => std::env::set_var(self.key, value),
|
|
None => std::env::remove_var(self.key),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::io::Read for MockStream {
|
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
|
self.input.read(buf)
|
|
}
|
|
}
|
|
|
|
impl std::io::Write for MockStream {
|
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
|
self.output.extend_from_slice(buf);
|
|
Ok(buf.len())
|
|
}
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Drive one request through `serve_one` and return the raw HTTP response.
|
|
fn serve(method: &str, path: &str, body: &str) -> String {
|
|
let request = format!(
|
|
"{method} {path} HTTP/1.1\r\nHost: x\r\nContent-Length: {}\r\n\r\n{body}",
|
|
body.len()
|
|
);
|
|
let mut stream = MockStream {
|
|
input: std::io::Cursor::new(request.into_bytes()),
|
|
output: Vec::new(),
|
|
};
|
|
let state = Mutex::new(fresh_state());
|
|
let hub = SseHub::default();
|
|
serve_one(&mut stream, &state, &hub).expect("serve_one");
|
|
String::from_utf8_lossy(&stream.output).into_owned()
|
|
}
|
|
|
|
fn mock_stream(request: &str) -> MockStream {
|
|
MockStream {
|
|
input: std::io::Cursor::new(request.as_bytes().to_vec()),
|
|
output: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sse_hub_broadcasts_version_to_all_subscribers() {
|
|
let hub = SseHub::default();
|
|
let a = hub.subscribe();
|
|
let b = hub.subscribe();
|
|
hub.broadcast(5);
|
|
assert_eq!(a.recv().unwrap(), 5);
|
|
assert_eq!(b.recv().unwrap(), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn sse_hub_prunes_disconnected_subscribers() {
|
|
let hub = SseHub::default();
|
|
let live = hub.subscribe();
|
|
drop(hub.subscribe()); // a disconnected client (receiver dropped)
|
|
assert_eq!(hub.subscriber_count(), 2);
|
|
hub.broadcast(1); // prunes the dropped one
|
|
assert_eq!(hub.subscriber_count(), 1);
|
|
assert_eq!(live.recv().unwrap(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn write_sse_event_emits_data_frame() {
|
|
let mut stream = mock_stream("");
|
|
write_sse_event(&mut stream, 42).expect("write");
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&stream.output),
|
|
"data: {\"version\":42}\n\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn serve_sse_emits_initial_then_each_version_until_hub_drops() {
|
|
let (tx, rx) = mpsc::channel();
|
|
tx.send(9).expect("send"); // one bump, then the sender drops → Disconnected
|
|
drop(tx);
|
|
let mut stream = mock_stream("");
|
|
serve_sse(&mut stream, rx, 7, Some("*")).expect("serve_sse");
|
|
let out = String::from_utf8_lossy(&stream.output);
|
|
assert!(out.contains("text/event-stream"), "{out}");
|
|
assert!(out.contains(r#"data: {"version":7}"#), "{out}"); // initial sync
|
|
assert!(out.contains(r#"data: {"version":9}"#), "{out}"); // broadcast bump
|
|
}
|
|
|
|
#[test]
|
|
fn serve_one_post_document_broadcasts_new_version_to_sse() {
|
|
let state = Mutex::new(fresh_state());
|
|
let hub = SseHub::default();
|
|
let sub = hub.subscribe();
|
|
let request = format!(
|
|
"POST /api/mcp/document HTTP/1.1\r\nHost: x\r\nContent-Length: {}\r\n\r\n{}",
|
|
SYNC_BODY.len(),
|
|
SYNC_BODY
|
|
);
|
|
let mut stream = mock_stream(&request);
|
|
serve_one(&mut stream, &state, &hub).expect("serve_one");
|
|
// The whole-doc sync bumped the version to 1 and broadcast it.
|
|
assert_eq!(sub.recv().unwrap(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn serve_one_routes_rest_health_and_document() {
|
|
assert!(serve("GET", "/api/mcp/server", "").contains("200 OK"));
|
|
assert!(serve("GET", "/api/mcp/document", "").contains("200 OK"));
|
|
let post = serve("POST", "/api/mcp/document", SYNC_BODY);
|
|
assert!(post.contains("200 OK"), "{post}");
|
|
assert!(post.contains(r#""ok":true"#));
|
|
}
|
|
|
|
#[test]
|
|
fn serve_one_standard_ai_route_is_sse_not_404() {
|
|
let r = serve("POST", "/api/ai/standard", "not json");
|
|
assert!(r.contains("text/event-stream"), "{r}");
|
|
assert!(r.contains("invalid request body"), "{r}");
|
|
assert!(!r.contains("404 Not Found"), "{r}");
|
|
}
|
|
|
|
#[test]
|
|
fn serve_one_query_string_does_not_break_rest_routing() {
|
|
// The query string must be stripped before exact-path routing.
|
|
assert!(serve("GET", "/api/mcp/server?v=2", "").contains("200 OK"));
|
|
}
|
|
|
|
#[test]
|
|
fn serve_one_post_mcp_dispatches_jsonrpc() {
|
|
let r = serve(
|
|
"POST",
|
|
"/mcp",
|
|
r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
|
|
);
|
|
assert!(r.contains("200 OK"), "{r}");
|
|
}
|
|
|
|
#[cfg(feature = "mcp-debug-tools")]
|
|
#[test]
|
|
fn serve_one_post_mcp_debug_screenshot_uses_web_canvas_renderer() {
|
|
let _debug_gate = EnvVarGuard::set("OPENPENCIL_DEBUG_TOOLS", "1");
|
|
let state = Mutex::new(fresh_state());
|
|
let hub = SseHub::default();
|
|
{
|
|
let mut guard = state.lock().expect("state lock");
|
|
let seeded = handle_web_canvas_request("POST", "/api/mcp/document", SYNC_BODY, &mut guard);
|
|
assert!(seeded.status.starts_with("200"), "{}", seeded.body);
|
|
}
|
|
|
|
let body = r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"debug_screenshot","arguments":{"target":"root","dpr":1}}}"#;
|
|
let request = format!(
|
|
"POST /mcp HTTP/1.1\r\nHost: x\r\nContent-Length: {}\r\n\r\n{body}",
|
|
body.len()
|
|
);
|
|
let mut stream = mock_stream(&request);
|
|
serve_one(&mut stream, &state, &hub).expect("serve_one");
|
|
let out = String::from_utf8_lossy(&stream.output);
|
|
assert!(out.contains("200 OK"), "{out}");
|
|
assert!(out.contains(r#""type":"image""#), "{out}");
|
|
assert!(out.contains(r#""mimeType":"image/png""#), "{out}");
|
|
assert!(
|
|
!out.contains("No live canvas available"),
|
|
"web daemon must serve screenshots from its live document, got {out}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn serve_one_get_mcp_is_405_not_a_tool_call() {
|
|
let r = serve("GET", "/mcp", "");
|
|
assert!(r.contains("405 Method Not Allowed"), "{r}");
|
|
}
|
|
|
|
#[test]
|
|
fn serve_one_unknown_path_is_404() {
|
|
let r = serve("GET", "/favicon.ico", "");
|
|
assert!(r.contains("404 Not Found"), "{r}");
|
|
}
|
|
|
|
#[test]
|
|
fn sync_reset_clears_web_document_and_bumps_version() {
|
|
use op_editor_core::PenNodeExt;
|
|
|
|
let mut s = fresh_state();
|
|
let posted = handle_web_canvas_request("POST", "/api/mcp/document", SYNC_BODY, &mut s);
|
|
assert!(posted.status.starts_with("200"), "{}", posted.body);
|
|
assert_eq!(s.version, 1);
|
|
assert!(
|
|
s.editor
|
|
.active_children()
|
|
.iter()
|
|
.any(|node| node.base().name.as_deref() == Some("Synced Rect")),
|
|
"fixture document should be present before reset"
|
|
);
|
|
|
|
let reset = handle_web_canvas_request("POST", "/api/mcp/sync-reset", "", &mut s);
|
|
assert!(reset.status.starts_with("200"), "{}", reset.body);
|
|
assert!(reset.body.contains(r#""ok":true"#), "{}", reset.body);
|
|
assert!(reset.body.contains(r#""version":2"#), "{}", reset.body);
|
|
assert_eq!(s.version, 2);
|
|
assert_eq!(s.editor.doc, EditorState::starter().doc);
|
|
assert!(
|
|
!s.editor
|
|
.active_children()
|
|
.iter()
|
|
.any(|node| node.base().name.as_deref() == Some("Synced Rect")),
|
|
"sync reset should remove the previous web document"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn second_sync_reset_is_skipped_and_mutates_nothing() {
|
|
let mut state = fresh_state();
|
|
let first = state.reset_document_guarded().unwrap();
|
|
assert!(!first.skipped);
|
|
// capture full post-reset identity: a skipped reset must not move EITHER
|
|
let v_before = state.document_version_for_test();
|
|
let doc_before = serde_json::to_string(&state.editor.doc).unwrap();
|
|
let second = state.reset_document_guarded().unwrap();
|
|
assert!(second.skipped);
|
|
assert_eq!(state.document_version_for_test(), v_before);
|
|
assert_eq!(
|
|
serde_json::to_string(&state.editor.doc).unwrap(),
|
|
doc_before
|
|
);
|
|
}
|
|
|
|
// A second valid body whose content DIFFERS from SYNC_BODY — rejected writes
|
|
// are asserted against it so "replace the doc but suppress the version bump"
|
|
// style bugs cannot pass.
|
|
const SYNC_BODY_ALT: &str = r##"{"document":{"version":"1.0.0","children":[{"id":"n77","type":"ellipse","name":"Rejected Ellipse","x":9,"y":9,"width":30,"height":30,"fill":[{"type":"solid","color":"#abcdef"}]}]},"sourceClientId":"web"}"##;
|
|
|
|
fn doc_fingerprint(state: &WebCanvasState) -> (u64, String) {
|
|
(
|
|
state.document_version_for_test(),
|
|
serde_json::to_string(&state.editor.doc).unwrap(),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn failed_reset_errors_and_mutates_nothing() {
|
|
let mut state = fresh_state();
|
|
state.current_path = Some(std::path::PathBuf::from("/nonexistent/x.op"));
|
|
let before = doc_fingerprint(&state);
|
|
// the reset MUST fail here — a silent success is itself a bug:
|
|
assert!(state.reset_document_guarded().is_err());
|
|
assert!(!state.reset_consumed); // retryable
|
|
assert_eq!(doc_fingerprint(&state), before); // version AND bytes untouched
|
|
}
|
|
|
|
#[test]
|
|
fn document_post_with_stale_base_version_conflicts() {
|
|
let mut state = fresh_state();
|
|
let v0 = state.document_version_for_test();
|
|
let ok = state.apply_document_push(SYNC_BODY, Some(v0)).unwrap();
|
|
assert!(ok.applied);
|
|
// capture BEFORE the stale attempt: a rejected write must change nothing
|
|
let before = doc_fingerprint(&state);
|
|
// stale write carries DIFFERENT bytes — proves the document didn't move
|
|
let stale = state.apply_document_push(SYNC_BODY_ALT, Some(v0)).unwrap();
|
|
assert!(!stale.applied);
|
|
assert_eq!(doc_fingerprint(&state), before); // no bump, no content swap
|
|
assert_eq!(stale.current_version, before.0);
|
|
}
|
|
|
|
#[test]
|
|
fn document_post_without_base_version_keeps_legacy_behavior() {
|
|
let mut state = fresh_state();
|
|
assert!(state.apply_document_push(SYNC_BODY, None).unwrap().applied);
|
|
assert!(state.apply_document_push(SYNC_BODY, None).unwrap().applied);
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_document_push_stays_an_error() {
|
|
let mut state = fresh_state();
|
|
assert!(state.apply_document_push("{not json", None).is_err()); // still HTTP 400
|
|
}
|
|
|
|
#[test]
|
|
fn base_version_is_extracted_from_the_request_body() {
|
|
let mut state = fresh_state();
|
|
// No override: the stale baseVersion inside the body itself must conflict.
|
|
let stale_in_body = SYNC_BODY.replacen(
|
|
r#""sourceClientId":"web""#,
|
|
r#""sourceClientId":"web","baseVersion":9999"#,
|
|
1,
|
|
);
|
|
let out = state.apply_document_push(&stale_in_body, None).unwrap();
|
|
assert!(!out.applied);
|
|
}
|
|
|
|
#[test]
|
|
fn sync_reset_route_reply_is_skipped_true_on_second_call() {
|
|
let mut s = fresh_state();
|
|
let first = handle_web_canvas_request("POST", "/api/mcp/sync-reset", "", &mut s);
|
|
assert!(first.status.starts_with("200"), "{}", first.body);
|
|
assert!(!first.body.contains(r#""skipped""#), "{}", first.body);
|
|
|
|
let second = handle_web_canvas_request("POST", "/api/mcp/sync-reset", "", &mut s);
|
|
assert!(second.status.starts_with("200"), "{}", second.body);
|
|
assert!(second.body.contains(r#""skipped":true"#), "{}", second.body);
|
|
assert!(second.body.contains(r#""version":1"#), "{}", second.body);
|
|
assert_eq!(s.version, 1, "a skipped reset must not bump the version");
|
|
}
|
|
|
|
#[test]
|
|
fn document_post_route_409s_on_stale_base_version_without_mutating() {
|
|
let mut s = fresh_state();
|
|
let body_with_base_version = SYNC_BODY.replacen(
|
|
r#""sourceClientId":"web""#,
|
|
r#""sourceClientId":"web","baseVersion":9999"#,
|
|
1,
|
|
);
|
|
let r = handle_web_canvas_request("POST", "/api/mcp/document", &body_with_base_version, &mut s);
|
|
assert!(r.status.starts_with("409"), "{}", r.body);
|
|
assert!(
|
|
r.body.contains(r#""error":"version-conflict""#),
|
|
"{}",
|
|
r.body
|
|
);
|
|
assert!(r.body.contains(r#""version":0"#), "{}", r.body);
|
|
assert_eq!(s.version, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn serve_one_unimplemented_api_route_is_404_not_jsonrpc() {
|
|
// An `/api/mcp/*` route this daemon doesn't implement must 404, not
|
|
// fall through to JSON-RPC dispatch.
|
|
let r = serve("POST", "/api/mcp/not-a-route", "");
|
|
assert!(r.contains("404 Not Found"), "{r}");
|
|
}
|
|
|
|
#[test]
|
|
fn serve_one_get_root_serves_html_not_jsonrpc() {
|
|
// `GET /` is the static host-page route now — text/html either way
|
|
// (200 host page with a bundle, 404 build-help page without one) and
|
|
// never the old 405 from the JSON-RPC path guard.
|
|
let r = serve("GET", "/", "");
|
|
assert!(r.contains("Content-Type: text/html"), "{r}");
|
|
assert!(!r.contains("405"), "{r}");
|
|
// `POST /` keeps dispatching JSON-RPC (web_static ignores non-GET).
|
|
let post = serve(
|
|
"POST",
|
|
"/",
|
|
r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
|
|
);
|
|
assert!(post.contains("200 OK"), "{post}");
|
|
assert!(post.contains(r#""tools""#), "{post}");
|
|
}
|
|
|
|
#[test]
|
|
fn serve_one_token_authed_shutdown_signals_caller() {
|
|
std::env::set_var("OPENPENCIL_MCP_TOKEN", "serve-web-shutdown-test");
|
|
let state = Mutex::new(fresh_state());
|
|
let hub = SseHub::default();
|
|
// Wrong token → NOT a shutdown (falls through to JSON-RPC dispatch).
|
|
let bad =
|
|
r#"{"jsonrpc":"2.0","id":1,"method":"openpencil/shutdown","params":{"token":"nope"}}"#;
|
|
let mut stream = mock_stream(&format!(
|
|
"POST /mcp HTTP/1.1\r\nHost: x\r\nContent-Length: {}\r\n\r\n{bad}",
|
|
bad.len()
|
|
));
|
|
let wants_shutdown = serve_one(&mut stream, &state, &hub).expect("serve_one");
|
|
assert!(!wants_shutdown, "a mismatched token must not shut down");
|
|
// Matching token → ack + shutdown signal for the accept loop.
|
|
let good = r#"{"jsonrpc":"2.0","id":2,"method":"openpencil/shutdown","params":{"token":"serve-web-shutdown-test"}}"#;
|
|
let mut stream = mock_stream(&format!(
|
|
"POST /mcp HTTP/1.1\r\nHost: x\r\nContent-Length: {}\r\n\r\n{good}",
|
|
good.len()
|
|
));
|
|
let wants_shutdown = serve_one(&mut stream, &state, &hub).expect("serve_one");
|
|
assert!(wants_shutdown);
|
|
let out = String::from_utf8_lossy(&stream.output);
|
|
assert!(out.contains(r#""shuttingDown":true"#), "{out}");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_serve_web_args_accepts_port_doc_and_host() {
|
|
let parse = |args: &[&str]| parse_serve_web_args(args.iter().map(|s| s.to_string()));
|
|
// Port only → loopback, empty document.
|
|
let o = parse(&["3100"]).expect("port only");
|
|
assert_eq!((o.port, o.managed), (3100, false));
|
|
assert_eq!(o.path, None);
|
|
assert_eq!(o.host, "127.0.0.1");
|
|
assert!(o.allow_origins.is_empty());
|
|
// Port + doc.
|
|
let o = parse(&["3100", "/tmp/d.op"]).expect("port+doc");
|
|
assert_eq!(o.port, 3100);
|
|
assert_eq!(o.path.as_deref(), Some(std::path::Path::new("/tmp/d.op")));
|
|
assert_eq!(o.host, "127.0.0.1");
|
|
// `--host` in both spellings, before or after the doc.
|
|
let o = parse(&["3100", "--host", "0.0.0.0", "/tmp/d.op"]).expect("host then doc");
|
|
assert_eq!(o.port, 3100);
|
|
assert_eq!(o.path.as_deref(), Some(std::path::Path::new("/tmp/d.op")));
|
|
assert_eq!(o.host, "0.0.0.0");
|
|
let o = parse(&["3100", "/tmp/d.op", "--host=0.0.0.0"]).expect("doc then host=");
|
|
assert_eq!(o.port, 3100);
|
|
assert_eq!(o.path.as_deref(), Some(std::path::Path::new("/tmp/d.op")));
|
|
assert_eq!(o.host, "0.0.0.0");
|
|
// Malformed shapes are rejected with a message, not silently dropped.
|
|
assert!(parse(&[]).is_err(), "missing port");
|
|
assert!(parse(&["nope"]).is_err(), "non-numeric port");
|
|
assert!(parse(&["3100", "--host"]).is_err(), "--host without value");
|
|
assert!(parse(&["3100", "a.op", "b.op"]).is_err(), "two docs");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_serve_web_args_legacy_positional_unchanged() {
|
|
let o = parse_serve_web_args(vec!["3100".into(), "doc.op".into()].into_iter()).unwrap();
|
|
assert_eq!((o.port, o.managed), (3100, false));
|
|
assert_eq!(o.path.as_deref(), Some(std::path::Path::new("doc.op")));
|
|
assert_eq!(o.host, "127.0.0.1");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_serve_web_args_managed_flag_form() {
|
|
let o = parse_serve_web_args(
|
|
vec![
|
|
"--managed".into(),
|
|
"--port".into(),
|
|
"0".into(),
|
|
"--file".into(),
|
|
"a.op".into(),
|
|
"--allow-origin".into(),
|
|
"vscode-webview://x".into(),
|
|
"--allow-origin".into(),
|
|
"vscode-webview://y".into(),
|
|
]
|
|
.into_iter(),
|
|
)
|
|
.unwrap();
|
|
assert!(o.managed);
|
|
assert_eq!(o.port, 0);
|
|
assert_eq!(o.path.as_deref(), Some(std::path::Path::new("a.op")));
|
|
assert_eq!(o.allow_origins.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn handshake_line_is_single_line_json() {
|
|
let line = handshake_json(41234, "aabbccdd00112233aabbccdd00112233");
|
|
assert!(!line.contains('\n'));
|
|
assert!(line.contains(r#""port":41234"#));
|
|
assert!(line.contains(r#""token":"aabbccdd00112233aabbccdd00112233""#));
|
|
}
|
|
|
|
#[test]
|
|
fn indicators_endpoint_serves_parseable_relay_json() {
|
|
let mut s = WebCanvasState::new(EditorState::starter(), 3100);
|
|
|
|
let r = handle_web_canvas_request("GET", "/api/mcp/indicators", "", &mut s);
|
|
|
|
assert!(r.status.starts_with("200"), "{}", r.body);
|
|
let remote = op_editor_core::agent_indicators::parse_relay_json(&r.body)
|
|
.expect("relay body parses back through the browser-side parser");
|
|
// No design run in this test process — idle registry relays as such.
|
|
assert!(!remote.run_active);
|
|
}
|
|
|
|
// --- Task 4: serve_one layer token auth + CORS allowlist ---
|
|
|
|
#[test]
|
|
fn managed_auth_gates_by_method_and_path() {
|
|
let auth = RequestAuth {
|
|
managed: true,
|
|
token: "tok123".into(),
|
|
};
|
|
assert!(auth.allows("GET", "/", None)); // static shell
|
|
assert!(auth.allows("GET", "/index.html", None));
|
|
assert!(auth.allows("GET", "/pkg/op_host_web.js", None));
|
|
assert!(auth.allows("GET", "/canvaskit/canvaskit.wasm", None)); // editor can't boot without
|
|
assert!(auth.allows("GET", "/assets/iconify-catalog-brands.json", None));
|
|
assert!(auth.allows("GET", "/smoke/step-1b.html", None));
|
|
assert!(auth.allows("OPTIONS", "/api/mcp/document", None)); // preflight
|
|
assert!(!auth.allows("POST", "/", None)); // JSON-RPC alias: privileged
|
|
assert!(!auth.allows("GET", "/api/mcp/events", None)); // SSE: privileged
|
|
assert!(!auth.allows("POST", "/mcp", Some("wrong")));
|
|
assert!(auth.allows("POST", "/mcp", Some("tok123")));
|
|
}
|
|
|
|
#[test]
|
|
fn unmanaged_mode_keeps_open_behavior() {
|
|
let auth = RequestAuth {
|
|
managed: false,
|
|
token: String::new(),
|
|
};
|
|
assert!(auth.allows("POST", "/api/mcp/document", None));
|
|
}
|
|
|
|
#[test]
|
|
fn cors_echoes_only_allowlisted_origin() {
|
|
let allow = vec!["vscode-webview://abc".to_string()];
|
|
assert_eq!(
|
|
cors_origin_for(&allow, Some("vscode-webview://abc")).as_deref(),
|
|
Some("vscode-webview://abc")
|
|
);
|
|
assert_eq!(cors_origin_for(&allow, Some("http://evil.local")), None);
|
|
assert_eq!(cors_origin_for(&allow, None), None);
|
|
}
|