Files
MyAiDesk/nomifun-tauri/crates/backend/nomifun-office/tests/proxy_integration.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

457 lines
16 KiB
Rust

use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use nomifun_api_types::WebSocketMessage;
use nomifun_office::OfficeError;
use nomifun_office::proxy::{ProxyError, ProxyService};
use nomifun_office::types::DocType;
use nomifun_office::watch_manager::{OfficecliWatchManager, ProcessHandle, ProcessSpawner};
use nomifun_realtime::EventBroadcaster;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
// ---------------------------------------------------------------------------
// Test infrastructure
// ---------------------------------------------------------------------------
struct MockProcessHandle {
alive: AtomicBool,
}
impl MockProcessHandle {
fn new() -> Self {
Self {
alive: AtomicBool::new(true),
}
}
}
impl ProcessHandle for MockProcessHandle {
fn kill(&self) {
self.alive.store(false, Ordering::SeqCst);
}
fn is_alive(&self) -> bool {
self.alive.load(Ordering::SeqCst)
}
}
struct HttpMockSpawner {
response_template: String,
}
#[async_trait::async_trait]
impl ProcessSpawner for HttpMockSpawner {
async fn spawn_officecli(
&self,
_file_path: &str,
port: u16,
_doc_type: DocType,
) -> Result<Box<dyn ProcessHandle>, OfficeError> {
let resp = self.response_template.replace("__PORT__", &port.to_string());
tokio::spawn(async move {
let listener = TcpListener::bind(format!("127.0.0.1:{port}")).await.unwrap();
for _ in 0..10 {
if let Ok((mut stream, _)) = listener.accept().await {
let resp = resp.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 4096];
let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
let _ = stream.write_all(resp.as_bytes()).await;
let _ = stream.shutdown().await;
});
}
}
});
Ok(Box::new(MockProcessHandle::new()))
}
async fn install_officecli(&self) -> Result<(), OfficeError> {
Ok(())
}
async fn is_officecli_installed(&self) -> bool {
true
}
async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> {
Ok(())
}
}
struct TcpOnlySpawner;
#[async_trait::async_trait]
impl ProcessSpawner for TcpOnlySpawner {
async fn spawn_officecli(
&self,
_file_path: &str,
port: u16,
_doc_type: DocType,
) -> Result<Box<dyn ProcessHandle>, OfficeError> {
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
.map_err(|e| OfficeError::StartFailed(e.to_string()))?;
std::mem::forget(listener);
Ok(Box::new(MockProcessHandle::new()))
}
async fn install_officecli(&self) -> Result<(), OfficeError> {
Ok(())
}
async fn is_officecli_installed(&self) -> bool {
true
}
async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> {
Ok(())
}
}
struct NoopBroadcaster;
impl EventBroadcaster for NoopBroadcaster {
fn broadcast(&self, _event: WebSocketMessage<serde_json::Value>) {}
}
fn build_http_response(status: u16, headers: &[(&str, &str)], body: &str) -> String {
let status_text = match status {
200 => "OK",
302 => "Found",
404 => "Not Found",
_ => "Unknown",
};
let mut resp = format!("HTTP/1.1 {status} {status_text}\r\n");
for (k, v) in headers {
resp.push_str(&format!("{k}: {v}\r\n"));
}
if !headers.iter().any(|(k, _)| k.to_lowercase() == "content-length") {
resp.push_str(&format!("Content-Length: {}\r\n", body.len()));
}
resp.push_str("\r\n");
resp.push_str(body);
resp
}
async fn setup_proxy(doc_type: DocType, response_template: &str) -> (ProxyService, u16, tempfile::TempDir) {
let spawner = HttpMockSpawner {
response_template: response_template.to_owned(),
};
let mgr = Arc::new(OfficecliWatchManager::new(Arc::new(spawner), Arc::new(NoopBroadcaster)));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.docx");
std::fs::write(&file, b"test").unwrap();
let port = mgr.start(file.to_str().unwrap(), doc_type).await.unwrap();
let proxy = ProxyService::new(mgr);
(proxy, port, dir)
}
async fn setup_ssrf_proxy(doc_type: DocType) -> (ProxyService, u16, tempfile::TempDir) {
let mgr = Arc::new(OfficecliWatchManager::new(
Arc::new(TcpOnlySpawner),
Arc::new(NoopBroadcaster),
));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.docx");
std::fs::write(&file, b"test").unwrap();
let port = mgr.start(file.to_str().unwrap(), doc_type).await.unwrap();
let proxy = ProxyService::new(mgr);
(proxy, port, dir)
}
// ---------------------------------------------------------------------------
// RP-2: PPT proxy SSRF protection — inactive port rejected
// ---------------------------------------------------------------------------
#[tokio::test]
async fn rp2_ppt_proxy_ssrf_rejects_inactive_port() {
let (proxy, _active_port, _dir) = setup_ssrf_proxy(DocType::Ppt).await;
let result = proxy.forward(9999, "/index.html", DocType::Ppt, &[]).await;
let err = result.unwrap_err();
assert!(matches!(err, ProxyError::PortNotActive(9999)));
}
// ---------------------------------------------------------------------------
// RP-4: Office watch proxy SSRF protection — inactive port rejected
// ---------------------------------------------------------------------------
#[tokio::test]
async fn rp4_office_watch_proxy_ssrf_rejects_inactive_port() {
let (proxy, _active_port, _dir) = setup_ssrf_proxy(DocType::Word).await;
let result = proxy.forward_watch(9999, "/", &[]).await;
assert!(matches!(result.unwrap_err(), ProxyError::PortNotActive(9999)));
}
// ---------------------------------------------------------------------------
// SSRF: wrong doc_type rejected even when port is active
// ---------------------------------------------------------------------------
#[tokio::test]
async fn ssrf_wrong_doc_type_rejected() {
let (proxy, active_port, _dir) = setup_ssrf_proxy(DocType::Word).await;
let result = proxy.forward(active_port, "/index.html", DocType::Ppt, &[]).await;
assert!(matches!(result.unwrap_err(), ProxyError::PortNotActive(_)));
}
// ---------------------------------------------------------------------------
// H-1-13.8 fix: forward_watch accepts Excel session ports
// ---------------------------------------------------------------------------
#[tokio::test]
async fn forward_watch_accepts_excel_session_port() {
let response = build_http_response(200, &[("Content-Type", "text/plain")], "Excel preview");
let (proxy, port, _dir) = setup_proxy(DocType::Excel, &response).await;
let result = proxy.forward_watch(port, "/", &[]).await.unwrap();
assert_eq!(result.status, 200);
let body = String::from_utf8(result.body).unwrap();
assert!(body.contains("Excel preview"));
}
// ---------------------------------------------------------------------------
// forward_watch accepts Word session ports
// ---------------------------------------------------------------------------
#[tokio::test]
async fn forward_watch_accepts_word_session_port() {
let response = build_http_response(200, &[("Content-Type", "text/plain")], "Word preview");
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response).await;
let result = proxy.forward_watch(port, "/", &[]).await.unwrap();
assert_eq!(result.status, 200);
let body = String::from_utf8(result.body).unwrap();
assert!(body.contains("Word preview"));
}
// ---------------------------------------------------------------------------
// forward_watch rejects PPT session ports
// ---------------------------------------------------------------------------
#[tokio::test]
async fn forward_watch_rejects_ppt_session_port() {
let (proxy, ppt_port, _dir) = setup_ssrf_proxy(DocType::Ppt).await;
let result = proxy.forward_watch(ppt_port, "/", &[]).await;
assert!(matches!(result.unwrap_err(), ProxyError::PortNotActive(_)));
}
// ---------------------------------------------------------------------------
// RP-1 / RP-3: Proxy forwards plain text response
// ---------------------------------------------------------------------------
#[tokio::test]
async fn rp1_rp3_proxy_forwards_plain_text() {
let response = build_http_response(200, &[("Content-Type", "text/plain")], "Hello from preview");
let (proxy, port, _dir) = setup_proxy(DocType::Ppt, &response).await;
let result = proxy.forward(port, "/index.html", DocType::Ppt, &[]).await.unwrap();
assert_eq!(result.status, 200);
let body = String::from_utf8(result.body).unwrap();
assert!(body.contains("Hello from preview"));
}
// ---------------------------------------------------------------------------
// RP-5: HTML injection — navigation guard script injected
// ---------------------------------------------------------------------------
#[tokio::test]
async fn rp5_proxy_injects_navigation_guard_in_html() {
let html_body = "<html><head><title>Preview</title></head><body>Content</body></html>";
let response = build_http_response(200, &[("Content-Type", "text/html")], html_body);
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response).await;
let result = proxy.forward(port, "/", DocType::Word, &[]).await.unwrap();
assert_eq!(result.status, 200);
let body = String::from_utf8(result.body).unwrap();
assert!(body.contains("<script>"), "should inject navigation guard script");
assert!(
body.contains(&format!("'/api/office-watch-proxy/{port}'")),
"guard should reference correct proxy base path"
);
assert!(
body.contains("<title>Preview</title>"),
"should preserve original HTML content"
);
}
// ---------------------------------------------------------------------------
// RP-5b: Non-HTML response should NOT inject script
// ---------------------------------------------------------------------------
#[tokio::test]
async fn rp5b_proxy_does_not_inject_in_json() {
let response = build_http_response(200, &[("Content-Type", "application/json")], r#"{"ok":true}"#);
let (proxy, port, _dir) = setup_proxy(DocType::Ppt, &response).await;
let result = proxy.forward(port, "/api/data", DocType::Ppt, &[]).await.unwrap();
let body = String::from_utf8(result.body).unwrap();
assert!(!body.contains("<script>"), "should not inject script in JSON responses");
}
// ---------------------------------------------------------------------------
// RP-7: Hop-by-hop header stripping
// ---------------------------------------------------------------------------
#[tokio::test]
async fn rp7_proxy_strips_hop_by_hop_headers() {
let response = build_http_response(
200,
&[
("Content-Type", "text/plain"),
("Connection", "keep-alive"),
("Keep-Alive", "timeout=5"),
("X-Custom", "preserved"),
],
"body",
);
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response).await;
let result = proxy.forward(port, "/", DocType::Word, &[]).await.unwrap();
let header_names: Vec<&str> = result.headers.iter().map(|(k, _)| k.as_str()).collect();
assert!(
!header_names.contains(&"connection"),
"connection header should be stripped"
);
assert!(
!header_names.contains(&"keep-alive"),
"keep-alive header should be stripped"
);
assert!(header_names.contains(&"x-custom"), "custom header should be preserved");
}
// ---------------------------------------------------------------------------
// X-Frame-Options set to SAMEORIGIN
// ---------------------------------------------------------------------------
#[tokio::test]
async fn proxy_sets_x_frame_options_sameorigin() {
let response = build_http_response(200, &[("Content-Type", "text/plain")], "body");
let (proxy, port, _dir) = setup_proxy(DocType::Ppt, &response).await;
let result = proxy.forward(port, "/", DocType::Ppt, &[]).await.unwrap();
let xfo = result
.headers
.iter()
.find(|(k, _)| k == "x-frame-options")
.map(|(_, v)| v.as_str());
assert_eq!(xfo, Some("SAMEORIGIN"));
}
// ---------------------------------------------------------------------------
// HTML content-length stripped after injection
// ---------------------------------------------------------------------------
#[tokio::test]
async fn proxy_removes_content_length_for_html() {
let html_body = "<html><head></head><body></body></html>";
let response = build_http_response(200, &[("Content-Type", "text/html")], html_body);
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response).await;
let result = proxy.forward(port, "/", DocType::Word, &[]).await.unwrap();
let has_cl = result.headers.iter().any(|(k, _)| k == "content-length");
assert!(
!has_cl,
"content-length should be stripped for HTML responses (body size changed after injection)"
);
}
// ---------------------------------------------------------------------------
// Non-HTML content-length preserved
// ---------------------------------------------------------------------------
#[tokio::test]
async fn proxy_preserves_content_length_for_non_html() {
let response = build_http_response(200, &[("Content-Type", "application/json")], r#"{"ok":true}"#);
let (proxy, port, _dir) = setup_proxy(DocType::Ppt, &response).await;
let result = proxy.forward(port, "/api/data", DocType::Ppt, &[]).await.unwrap();
let has_cl = result.headers.iter().any(|(k, _)| k == "content-length");
assert!(has_cl, "content-length should be preserved for non-HTML");
}
// ---------------------------------------------------------------------------
// RP-6: Location rewriting
// ---------------------------------------------------------------------------
#[tokio::test]
async fn rp6_proxy_rewrites_location_header() {
let response_template = build_http_response(
302,
&[
("Content-Type", "text/html"),
("Location", "http://localhost:__PORT__/new/path"),
],
"",
);
let (proxy, port, _dir) = setup_proxy(DocType::Ppt, &response_template).await;
let result = proxy.forward(port, "/old", DocType::Ppt, &[]).await.unwrap();
assert_eq!(result.status, 302);
let location = result
.headers
.iter()
.find(|(k, _)| k == "location")
.map(|(_, v)| v.as_str());
assert_eq!(location, Some(format!("/api/ppt-proxy/{port}/new/path").as_str()));
}
// ---------------------------------------------------------------------------
// RP-6b: Location rewriting for root-relative paths
// ---------------------------------------------------------------------------
#[tokio::test]
async fn rp6b_proxy_rewrites_root_relative_location() {
let response_template = build_http_response(302, &[("Content-Type", "text/html"), ("Location", "/redirected")], "");
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response_template).await;
let result = proxy.forward(port, "/old", DocType::Word, &[]).await.unwrap();
let location = result
.headers
.iter()
.find(|(k, _)| k == "location")
.map(|(_, v)| v.as_str());
assert_eq!(
location,
Some(format!("/api/office-watch-proxy/{port}/redirected").as_str())
);
}
// ---------------------------------------------------------------------------
// Proxy forwards 404 status correctly
// ---------------------------------------------------------------------------
#[tokio::test]
async fn proxy_forwards_404_status() {
let response = build_http_response(404, &[("Content-Type", "text/plain")], "Not Found");
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response).await;
let result = proxy.forward(port, "/missing", DocType::Word, &[]).await.unwrap();
assert_eq!(result.status, 404);
}