Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
use nomifun_api_types::ConversionTarget;
|
||||
use nomifun_office::ConversionService;
|
||||
use rust_xlsxwriter::{Format, Workbook};
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_simple_xlsx(dir: &TempDir) -> PathBuf {
|
||||
let path = dir.path().join("test.xlsx");
|
||||
let mut wb = Workbook::new();
|
||||
let sheet = wb.add_worksheet();
|
||||
sheet.set_name("Sheet1").unwrap();
|
||||
sheet.write_string(0, 0, "Name").unwrap();
|
||||
sheet.write_string(0, 1, "Age").unwrap();
|
||||
sheet.write_string(1, 0, "Alice").unwrap();
|
||||
sheet.write_number(1, 1, 30.0).unwrap();
|
||||
sheet.write_string(2, 0, "Bob").unwrap();
|
||||
sheet.write_number(2, 1, 25.0).unwrap();
|
||||
wb.save(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn create_multi_sheet_xlsx(dir: &TempDir) -> PathBuf {
|
||||
let path = dir.path().join("multi.xlsx");
|
||||
let mut wb = Workbook::new();
|
||||
|
||||
let s1 = wb.add_worksheet();
|
||||
s1.set_name("Users").unwrap();
|
||||
s1.write_string(0, 0, "Name").unwrap();
|
||||
s1.write_number(0, 1, 1.0).unwrap();
|
||||
|
||||
let s2 = wb.add_worksheet();
|
||||
s2.set_name("Products").unwrap();
|
||||
s2.write_string(0, 0, "Item").unwrap();
|
||||
s2.write_number(0, 1, 9.99).unwrap();
|
||||
|
||||
let s3 = wb.add_worksheet();
|
||||
s3.set_name("Empty").unwrap();
|
||||
|
||||
wb.save(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn create_xlsx_with_merges(dir: &TempDir) -> PathBuf {
|
||||
let path = dir.path().join("merged.xlsx");
|
||||
let mut wb = Workbook::new();
|
||||
let sheet = wb.add_worksheet();
|
||||
sheet.set_name("Merged").unwrap();
|
||||
let fmt = Format::new();
|
||||
sheet.merge_range(0, 0, 1, 2, "Merged Title", &fmt).unwrap();
|
||||
sheet.write_string(2, 0, "A").unwrap();
|
||||
sheet.write_string(2, 1, "B").unwrap();
|
||||
sheet.write_string(2, 2, "C").unwrap();
|
||||
wb.save(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[allow(clippy::approx_constant)] // 3.14 is test data, not an approximation of PI
|
||||
fn create_xlsx_with_types(dir: &TempDir) -> PathBuf {
|
||||
let path = dir.path().join("types.xlsx");
|
||||
let mut wb = Workbook::new();
|
||||
let sheet = wb.add_worksheet();
|
||||
sheet.set_name("Types").unwrap();
|
||||
sheet.write_string(0, 0, "text").unwrap();
|
||||
sheet.write_number(0, 1, 42.0).unwrap();
|
||||
sheet.write_number(0, 2, 3.14).unwrap();
|
||||
sheet.write_boolean(0, 3, true).unwrap();
|
||||
wb.save(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
// DC-1: Excel → JSON (normal)
|
||||
#[tokio::test]
|
||||
async fn dc1_excel_to_json_simple() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = create_simple_xlsx(&dir);
|
||||
let svc = ConversionService::new(None);
|
||||
|
||||
let resp = svc
|
||||
.convert(path.to_str().unwrap(), ConversionTarget::ExcelJson)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.to, "excel-json");
|
||||
assert!(resp.result.success);
|
||||
assert!(resp.result.error.is_none());
|
||||
|
||||
let data = resp.result.data.unwrap();
|
||||
let sheets = data["sheets"].as_array().unwrap();
|
||||
assert_eq!(sheets.len(), 1);
|
||||
assert_eq!(sheets[0]["name"], "Sheet1");
|
||||
|
||||
let rows = sheets[0]["data"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(rows[0][0], "Name");
|
||||
assert_eq!(rows[0][1], "Age");
|
||||
assert_eq!(rows[1][0], "Alice");
|
||||
assert_eq!(rows[1][1], 30.0);
|
||||
assert_eq!(rows[2][0], "Bob");
|
||||
assert_eq!(rows[2][1], 25.0);
|
||||
}
|
||||
|
||||
// DC-2: Excel → JSON (multiple sheets)
|
||||
#[tokio::test]
|
||||
async fn dc2_excel_to_json_multi_sheet() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = create_multi_sheet_xlsx(&dir);
|
||||
let svc = ConversionService::new(None);
|
||||
|
||||
let resp = svc
|
||||
.convert(path.to_str().unwrap(), ConversionTarget::ExcelJson)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(resp.result.success);
|
||||
let data = resp.result.data.unwrap();
|
||||
let sheets = data["sheets"].as_array().unwrap();
|
||||
assert_eq!(sheets.len(), 3);
|
||||
assert_eq!(sheets[0]["name"], "Users");
|
||||
assert_eq!(sheets[1]["name"], "Products");
|
||||
assert_eq!(sheets[2]["name"], "Empty");
|
||||
}
|
||||
|
||||
// DC-3: Excel → JSON (with merged cells)
|
||||
#[tokio::test]
|
||||
async fn dc3_excel_to_json_with_merges() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = create_xlsx_with_merges(&dir);
|
||||
let svc = ConversionService::new(None);
|
||||
|
||||
let resp = svc
|
||||
.convert(path.to_str().unwrap(), ConversionTarget::ExcelJson)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(resp.result.success);
|
||||
let data = resp.result.data.unwrap();
|
||||
let sheet = &data["sheets"][0];
|
||||
assert_eq!(sheet["name"], "Merged");
|
||||
|
||||
let merges = sheet["merges"].as_array().unwrap();
|
||||
assert!(!merges.is_empty());
|
||||
|
||||
let merge = &merges[0];
|
||||
assert_eq!(merge["s"]["r"], 0);
|
||||
assert_eq!(merge["s"]["c"], 0);
|
||||
assert_eq!(merge["e"]["r"], 1);
|
||||
assert_eq!(merge["e"]["c"], 2);
|
||||
}
|
||||
|
||||
// DC-4: Excel → JSON (file not found)
|
||||
#[tokio::test]
|
||||
async fn dc4_excel_to_json_file_not_found() {
|
||||
let svc = ConversionService::new(None);
|
||||
let resp = svc
|
||||
.convert("/nonexistent/file.xlsx", ConversionTarget::ExcelJson)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.to, "excel-json");
|
||||
assert!(!resp.result.success);
|
||||
assert!(resp.result.data.is_none());
|
||||
assert!(resp.result.error.as_ref().unwrap().contains("file not found"));
|
||||
}
|
||||
|
||||
// DC-6: Word → Markdown (pandoc not available)
|
||||
#[tokio::test]
|
||||
async fn dc6_word_to_markdown_file_not_found() {
|
||||
let svc = ConversionService::new(None);
|
||||
let resp = svc
|
||||
.convert("/nonexistent/file.docx", ConversionTarget::Markdown)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.to, "markdown");
|
||||
assert!(!resp.result.success);
|
||||
assert!(resp.result.error.as_ref().unwrap().contains("file not found"));
|
||||
}
|
||||
|
||||
// DC-8: PPT → JSON (officecli not available)
|
||||
#[tokio::test]
|
||||
async fn dc8_ppt_to_json_file_not_found() {
|
||||
let svc = ConversionService::new(None);
|
||||
let resp = svc
|
||||
.convert("/nonexistent/file.pptx", ConversionTarget::PptJson)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.to, "ppt-json");
|
||||
assert!(!resp.result.success);
|
||||
assert!(resp.result.error.as_ref().unwrap().contains("file not found"));
|
||||
}
|
||||
|
||||
// DC-8b: PPT → JSON (officecli not installed — configured path invalid and not in PATH)
|
||||
#[tokio::test]
|
||||
async fn dc8b_ppt_to_json_officecli_not_installed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("slides.pptx");
|
||||
std::fs::write(&path, b"fake pptx content").unwrap();
|
||||
|
||||
// Use a wrapper that ensures officecli is not found
|
||||
let svc = ConversionService::new(Some("/nonexistent/officecli".into()));
|
||||
let resp = svc
|
||||
.convert(path.to_str().unwrap(), ConversionTarget::PptJson)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Conversion should fail — either because officecli is not found or because
|
||||
// it fails to parse the fake file. Either way, success must be false.
|
||||
assert!(!resp.result.success);
|
||||
assert!(resp.result.error.is_some());
|
||||
}
|
||||
|
||||
// Excel cell type handling
|
||||
#[tokio::test]
|
||||
#[allow(clippy::approx_constant)] // 3.14 is test data, not an approximation of PI
|
||||
async fn excel_to_json_cell_types() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = create_xlsx_with_types(&dir);
|
||||
let svc = ConversionService::new(None);
|
||||
|
||||
let resp = svc
|
||||
.convert(path.to_str().unwrap(), ConversionTarget::ExcelJson)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(resp.result.success);
|
||||
let data = resp.result.data.unwrap();
|
||||
let row = &data["sheets"][0]["data"][0];
|
||||
|
||||
assert_eq!(row[0], "text");
|
||||
assert_eq!(row[1], 42.0);
|
||||
assert!((row[2].as_f64().unwrap() - 3.14).abs() < 0.01);
|
||||
assert_eq!(row[3], true);
|
||||
}
|
||||
|
||||
// Excel empty file
|
||||
#[tokio::test]
|
||||
async fn excel_to_json_empty_workbook() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("empty.xlsx");
|
||||
let mut wb = Workbook::new();
|
||||
wb.add_worksheet().set_name("Empty").unwrap();
|
||||
wb.save(&path).unwrap();
|
||||
|
||||
let svc = ConversionService::new(None);
|
||||
let resp = svc
|
||||
.convert(path.to_str().unwrap(), ConversionTarget::ExcelJson)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(resp.result.success);
|
||||
let data = resp.result.data.unwrap();
|
||||
let sheets = data["sheets"].as_array().unwrap();
|
||||
assert_eq!(sheets.len(), 1);
|
||||
assert_eq!(sheets[0]["name"], "Empty");
|
||||
}
|
||||
|
||||
// Response always returns success=true at convert() level (error wrapped in result)
|
||||
#[tokio::test]
|
||||
async fn convert_always_returns_ok_with_result_wrapper() {
|
||||
let svc = ConversionService::new(None);
|
||||
|
||||
let targets = [
|
||||
ConversionTarget::Markdown,
|
||||
ConversionTarget::ExcelJson,
|
||||
ConversionTarget::PptJson,
|
||||
];
|
||||
|
||||
for target in targets {
|
||||
let resp = svc.convert("/nonexistent/path", target).await;
|
||||
assert!(resp.is_ok());
|
||||
assert!(!resp.unwrap().result.success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
use nomifun_api_types::{PreviewHistoryTargetDto, PreviewSnapshotInfoDto};
|
||||
use nomifun_common::PreviewContentType;
|
||||
use nomifun_office::SnapshotService;
|
||||
|
||||
fn make_target(content_type: PreviewContentType, file_path: Option<&str>) -> PreviewHistoryTargetDto {
|
||||
PreviewHistoryTargetDto {
|
||||
content_type,
|
||||
file_path: file_path.map(String::from),
|
||||
workspace: None,
|
||||
file_name: None,
|
||||
title: None,
|
||||
language: None,
|
||||
conversation_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_target_full(
|
||||
content_type: PreviewContentType,
|
||||
file_path: Option<&str>,
|
||||
workspace: Option<&str>,
|
||||
conversation_id: Option<i64>,
|
||||
) -> PreviewHistoryTargetDto {
|
||||
PreviewHistoryTargetDto {
|
||||
content_type,
|
||||
file_path: file_path.map(String::from),
|
||||
workspace: workspace.map(String::from),
|
||||
file_name: None,
|
||||
title: None,
|
||||
language: None,
|
||||
conversation_id,
|
||||
}
|
||||
}
|
||||
|
||||
// SH-1: Save snapshot
|
||||
#[tokio::test]
|
||||
async fn sh1_save_snapshot() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
|
||||
let info = svc.save(&target, "# Hello").await.unwrap();
|
||||
|
||||
assert!(!info.id.is_empty(), "id must not be empty");
|
||||
assert!(info.created_at > 0, "createdAt must be current timestamp");
|
||||
assert_eq!(info.size, 7, "size must be content byte count");
|
||||
assert_eq!(info.content_type, PreviewContentType::Markdown);
|
||||
assert!(!info.label.is_empty(), "label must not be empty");
|
||||
}
|
||||
|
||||
// SH-2: List snapshots (ordered by createdAt)
|
||||
#[tokio::test]
|
||||
async fn sh2_list_snapshots_ordered() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
|
||||
let s1 = svc.save(&target, "content-1").await.unwrap();
|
||||
let s2 = svc.save(&target, "content-2").await.unwrap();
|
||||
let s3 = svc.save(&target, "content-3").await.unwrap();
|
||||
|
||||
let list = svc.list(&target).await.unwrap();
|
||||
assert_eq!(list.len(), 3);
|
||||
|
||||
assert_eq!(list[0].id, s1.id);
|
||||
assert_eq!(list[1].id, s2.id);
|
||||
assert_eq!(list[2].id, s3.id);
|
||||
assert!(list[0].created_at <= list[1].created_at);
|
||||
assert!(list[1].created_at <= list[2].created_at);
|
||||
}
|
||||
|
||||
// SH-3: Get snapshot content
|
||||
#[tokio::test]
|
||||
async fn sh3_get_snapshot_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
|
||||
let info = svc.save(&target, "# Hello").await.unwrap();
|
||||
let resp = svc.get_content(&target, &info.id).await.unwrap();
|
||||
|
||||
assert!(resp.is_some());
|
||||
let resp = resp.unwrap();
|
||||
assert_eq!(resp.content, "# Hello");
|
||||
assert_eq!(resp.snapshot.id, info.id);
|
||||
assert_eq!(resp.snapshot.size, info.size);
|
||||
assert_eq!(resp.snapshot.content_type, PreviewContentType::Markdown);
|
||||
}
|
||||
|
||||
// SH-4: Get nonexistent snapshot returns None
|
||||
#[tokio::test]
|
||||
async fn sh4_get_nonexistent_snapshot() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
|
||||
let resp = svc.get_content(&target, "nonexistent").await.unwrap();
|
||||
assert!(resp.is_none());
|
||||
}
|
||||
|
||||
// SH-4b: Get nonexistent snapshot from a target with existing snapshots
|
||||
#[tokio::test]
|
||||
async fn sh4b_get_nonexistent_snapshot_with_existing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
|
||||
svc.save(&target, "some content").await.unwrap();
|
||||
let resp = svc.get_content(&target, "does-not-exist").await.unwrap();
|
||||
assert!(resp.is_none());
|
||||
}
|
||||
|
||||
// SH-5: Trim snapshots over limit (50)
|
||||
#[tokio::test]
|
||||
async fn sh5_trim_over_limit() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Code, Some("/c.rs"));
|
||||
|
||||
let mut first_ids: Vec<String> = Vec::new();
|
||||
for i in 0..51 {
|
||||
let info = svc.save(&target, &format!("content-{i}")).await.unwrap();
|
||||
if i == 0 {
|
||||
first_ids.push(info.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let list = svc.list(&target).await.unwrap();
|
||||
assert_eq!(list.len(), 50, "must trim to 50 snapshots");
|
||||
|
||||
assert!(
|
||||
!list.iter().any(|s| s.id == first_ids[0]),
|
||||
"oldest snapshot must be removed"
|
||||
);
|
||||
}
|
||||
|
||||
// SH-5b: Verify snapshot file is also deleted
|
||||
#[tokio::test]
|
||||
async fn sh5b_trim_deletes_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Code, Some("/c.rs"));
|
||||
|
||||
let first = svc.save(&target, "first").await.unwrap();
|
||||
for i in 1..51 {
|
||||
svc.save(&target, &format!("content-{i}")).await.unwrap();
|
||||
}
|
||||
|
||||
let resp = svc.get_content(&target, &first.id).await.unwrap();
|
||||
assert!(resp.is_none(), "trimmed snapshot file must not be readable");
|
||||
}
|
||||
|
||||
// SH-6: Different targets are isolated
|
||||
#[tokio::test]
|
||||
async fn sh6_different_targets_isolated() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let t1 = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
let t2 = make_target(PreviewContentType::Markdown, Some("/b.md"));
|
||||
|
||||
svc.save(&t1, "content-a").await.unwrap();
|
||||
svc.save(&t2, "content-b1").await.unwrap();
|
||||
svc.save(&t2, "content-b2").await.unwrap();
|
||||
|
||||
let list1 = svc.list(&t1).await.unwrap();
|
||||
let list2 = svc.list(&t2).await.unwrap();
|
||||
assert_eq!(list1.len(), 1);
|
||||
assert_eq!(list2.len(), 2);
|
||||
}
|
||||
|
||||
// SH-6b: Different content types are isolated
|
||||
#[tokio::test]
|
||||
async fn sh6b_different_content_types_isolated() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let t1 = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
let t2 = make_target(PreviewContentType::Html, Some("/a.md"));
|
||||
|
||||
svc.save(&t1, "md content").await.unwrap();
|
||||
svc.save(&t2, "html content").await.unwrap();
|
||||
|
||||
let list1 = svc.list(&t1).await.unwrap();
|
||||
let list2 = svc.list(&t2).await.unwrap();
|
||||
assert_eq!(list1.len(), 1);
|
||||
assert_eq!(list2.len(), 1);
|
||||
}
|
||||
|
||||
// SH-7: Target field combination produces different SHA-1 directories
|
||||
#[tokio::test]
|
||||
async fn sh7_target_field_combination_different_hash() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
|
||||
let t1 = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
let t2 = make_target_full(PreviewContentType::Markdown, Some("/a.md"), Some("/ws"), Some(1));
|
||||
|
||||
svc.save(&t1, "content-1").await.unwrap();
|
||||
svc.save(&t2, "content-2").await.unwrap();
|
||||
|
||||
let list1 = svc.list(&t1).await.unwrap();
|
||||
let list2 = svc.list(&t2).await.unwrap();
|
||||
assert_eq!(list1.len(), 1);
|
||||
assert_eq!(list2.len(), 1);
|
||||
|
||||
let r1 = svc.get_content(&t1, &list1[0].id).await.unwrap().unwrap();
|
||||
let r2 = svc.get_content(&t2, &list2[0].id).await.unwrap().unwrap();
|
||||
assert_eq!(r1.content, "content-1");
|
||||
assert_eq!(r2.content, "content-2");
|
||||
}
|
||||
|
||||
// SH-7b: Verify SHA-1 directory naming by inspecting filesystem
|
||||
#[tokio::test]
|
||||
async fn sh7b_sha1_directory_naming() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
|
||||
|
||||
svc.save(&target, "test").await.unwrap();
|
||||
|
||||
let history_dir = tmp.path().join("preview-history");
|
||||
let mut entries = std::fs::read_dir(&history_dir).unwrap();
|
||||
let dir_entry = entries.next().unwrap().unwrap();
|
||||
let dir_name = dir_entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
assert_eq!(dir_name.len(), 40, "SHA-1 hex must be 40 characters");
|
||||
assert!(
|
||||
dir_name.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"directory name must be hex"
|
||||
);
|
||||
|
||||
let index = std::fs::read_to_string(dir_entry.path().join("index.json")).unwrap();
|
||||
let snapshots: Vec<PreviewSnapshotInfoDto> = serde_json::from_str(&index).unwrap();
|
||||
assert_eq!(snapshots.len(), 1);
|
||||
}
|
||||
|
||||
// Extra: List on empty directory returns empty vec
|
||||
#[tokio::test]
|
||||
async fn list_empty_returns_empty_vec() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = make_target(PreviewContentType::Pdf, Some("/doc.pdf"));
|
||||
|
||||
let list = svc.list(&target).await.unwrap();
|
||||
assert!(list.is_empty());
|
||||
}
|
||||
|
||||
// Extra: Save preserves file_name and file_path from target
|
||||
#[tokio::test]
|
||||
async fn save_preserves_target_metadata() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let svc = SnapshotService::new(tmp.path());
|
||||
let target = PreviewHistoryTargetDto {
|
||||
content_type: PreviewContentType::Word,
|
||||
file_path: Some("/docs/report.docx".into()),
|
||||
workspace: None,
|
||||
file_name: Some("report.docx".into()),
|
||||
title: None,
|
||||
language: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let info = svc.save(&target, "word content").await.unwrap();
|
||||
assert_eq!(info.file_path.as_deref(), Some("/docs/report.docx"));
|
||||
assert_eq!(info.file_name.as_deref(), Some("report.docx"));
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
use std::net::TcpListener;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_office::StarOfficeDetector;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
fn allocate_port() -> u16 {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
async fn mock_star_office_server(
|
||||
port: u16,
|
||||
health_ok: bool,
|
||||
status_body: &'static str,
|
||||
index_body: &'static str,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut stream, _) = match listener.accept().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => break,
|
||||
};
|
||||
let status_body = status_body.to_string();
|
||||
let index_body = index_body.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let n = match stream.read(&mut buf).await {
|
||||
Ok(n) => n,
|
||||
Err(_) => return,
|
||||
};
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
|
||||
let path = request
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.unwrap_or("/");
|
||||
|
||||
let (status_line, body) = match path {
|
||||
"/health" => {
|
||||
if health_ok {
|
||||
("HTTP/1.1 200 OK", "ok".to_string())
|
||||
} else {
|
||||
("HTTP/1.1 503 Service Unavailable", "down".to_string())
|
||||
}
|
||||
}
|
||||
"/status" => ("HTTP/1.1 200 OK", status_body),
|
||||
"/" => ("HTTP/1.1 200 OK", index_body),
|
||||
_ => ("HTTP/1.1 404 Not Found", "not found".to_string()),
|
||||
};
|
||||
|
||||
let response = format!(
|
||||
"{status_line}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.shutdown().await;
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SO-1: No available service → returns None
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn so1_no_service_returns_none() {
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let port = allocate_port();
|
||||
let url = format!("http://localhost:{port}");
|
||||
let result = detector.detect_exact(Some(&url), true, Some(50)).await;
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SO-2: With preferred URL, no service → returns None
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn so2_preferred_url_no_service() {
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let result = detector
|
||||
.detect_exact(Some("http://localhost:59990"), true, Some(50))
|
||||
.await;
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SO-3: Cache behavior — second call hits cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn so3_cache_hit_returns_cached() {
|
||||
let port = allocate_port();
|
||||
let url = format!("http://localhost:{port}");
|
||||
let detector = Arc::new(StarOfficeDetector::new(reqwest::Client::new()));
|
||||
|
||||
let _ = detector.detect_exact(Some(&url), false, Some(50)).await;
|
||||
|
||||
let t0 = tokio::time::Instant::now();
|
||||
let result = detector.detect_exact(Some(&url), false, Some(50)).await;
|
||||
let elapsed = t0.elapsed();
|
||||
|
||||
assert!(result.is_none());
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_millis(100),
|
||||
"cached call should be fast, took {elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SO-4: Force ignores cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn so4_force_bypasses_cache() {
|
||||
let port = allocate_port();
|
||||
let url = format!("http://localhost:{port}");
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
|
||||
let _ = detector.detect_exact(Some(&url), false, Some(50)).await;
|
||||
let result = detector.detect_exact(Some(&url), true, Some(50)).await;
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SO-5: Detect available service (three-step health check)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn so5_detect_available_service() {
|
||||
let port = allocate_port();
|
||||
let handle = mock_star_office_server(
|
||||
port,
|
||||
true,
|
||||
r#"{"status": "idle"}"#,
|
||||
"<html><head></head><body>Star Office dashboard with decorate room</body></html>",
|
||||
)
|
||||
.await;
|
||||
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let url = format!("http://localhost:{port}");
|
||||
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
|
||||
|
||||
assert_eq!(result, Some(format!("http://localhost:{port}")));
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SO-6: Exclude OpenClaw misidentification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn so6_exclude_openclaw() {
|
||||
let port = allocate_port();
|
||||
let handle = mock_star_office_server(
|
||||
port,
|
||||
true,
|
||||
r#"{"status": "idle"}"#,
|
||||
"<html><head></head><body>Star Office with openclaw control panel</body></html>",
|
||||
)
|
||||
.await;
|
||||
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let url = format!("http://localhost:{port}");
|
||||
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
|
||||
|
||||
assert!(result.is_none());
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check step 1 fail: /health returns non-200
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_step1_fail_returns_none() {
|
||||
let port = allocate_port();
|
||||
let handle = mock_star_office_server(
|
||||
port,
|
||||
false,
|
||||
r#"{"status": "idle"}"#,
|
||||
"<html><body>Star Office</body></html>",
|
||||
)
|
||||
.await;
|
||||
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let url = format!("http://localhost:{port}");
|
||||
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
|
||||
|
||||
assert!(result.is_none());
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check step 2 fail: /status has no status markers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_step2_no_status_markers() {
|
||||
let port = allocate_port();
|
||||
let handle = mock_star_office_server(
|
||||
port,
|
||||
true,
|
||||
"just some random text",
|
||||
"<html><body>Star Office</body></html>",
|
||||
)
|
||||
.await;
|
||||
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let url = format!("http://localhost:{port}");
|
||||
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
|
||||
|
||||
assert!(result.is_none());
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check step 3 fail: / has no feature keywords
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_step3_no_feature_keywords() {
|
||||
let port = allocate_port();
|
||||
let handle = mock_star_office_server(
|
||||
port,
|
||||
true,
|
||||
r#"{"status": "idle"}"#,
|
||||
"<html><body>Some other application</body></html>",
|
||||
)
|
||||
.await;
|
||||
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let url = format!("http://localhost:{port}");
|
||||
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
|
||||
|
||||
assert!(result.is_none());
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detect with different status markers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_with_writing_status() {
|
||||
let port = allocate_port();
|
||||
let handle = mock_star_office_server(
|
||||
port,
|
||||
true,
|
||||
r#"{"status": "writing"}"#,
|
||||
"<html><body>decorate room and asset sidebar</body></html>",
|
||||
)
|
||||
.await;
|
||||
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let url = format!("http://localhost:{port}");
|
||||
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
|
||||
|
||||
assert_eq!(result, Some(format!("http://localhost:{port}")));
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache stores hit correctly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_stores_hit_after_success() {
|
||||
let port = allocate_port();
|
||||
let handle =
|
||||
mock_star_office_server(port, true, r#"idle"#, "<html><body>star office dashboard</body></html>").await;
|
||||
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let url = format!("http://localhost:{port}");
|
||||
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
|
||||
assert!(result.is_some());
|
||||
|
||||
handle.abort();
|
||||
|
||||
let cached = detector.detect_exact(Some(&url), false, Some(50)).await;
|
||||
assert_eq!(cached, result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache miss TTL expires quickly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_miss_ttl_expires() {
|
||||
let detector = StarOfficeDetector::new(reqwest::Client::new());
|
||||
let port = allocate_port();
|
||||
let url = format!("http://localhost:{port}");
|
||||
|
||||
let _ = detector.detect_exact(Some(&url), false, Some(50)).await;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1600)).await;
|
||||
|
||||
let port2 = allocate_port();
|
||||
let handle = mock_star_office_server(port2, true, "idle", "<html><body>star office</body></html>").await;
|
||||
|
||||
let url2 = format!("http://localhost:{port2}");
|
||||
let result = detector.detect_exact(Some(&url2), false, Some(2000)).await;
|
||||
assert_eq!(result, Some(format!("http://localhost:{port2}")));
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_office::{DocType, OfficeError, OfficecliWatchManager, ProcessHandle, ProcessSpawner};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test doubles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct MockHandle {
|
||||
alive: AtomicBool,
|
||||
}
|
||||
|
||||
impl MockHandle {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
alive: AtomicBool::new(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProcessHandle for MockHandle {
|
||||
fn kill(&self) {
|
||||
self.alive.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn is_alive(&self) -> bool {
|
||||
self.alive.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
struct TestSpawner {
|
||||
installed: AtomicBool,
|
||||
spawn_count: AtomicU32,
|
||||
install_count: AtomicU32,
|
||||
}
|
||||
|
||||
impl TestSpawner {
|
||||
fn new(installed: bool) -> Self {
|
||||
Self {
|
||||
installed: AtomicBool::new(installed),
|
||||
spawn_count: AtomicU32::new(0),
|
||||
install_count: AtomicU32::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ProcessSpawner for TestSpawner {
|
||||
async fn spawn_officecli(
|
||||
&self,
|
||||
_file_path: &str,
|
||||
port: u16,
|
||||
_doc_type: DocType,
|
||||
) -> Result<Box<dyn ProcessHandle>, OfficeError> {
|
||||
self.spawn_count.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
if !self.installed.load(Ordering::SeqCst) {
|
||||
return Err(OfficeError::OfficecliNotFound);
|
||||
}
|
||||
|
||||
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(MockHandle::new()))
|
||||
}
|
||||
|
||||
async fn install_officecli(&self) -> Result<(), OfficeError> {
|
||||
self.install_count.fetch_add(1, Ordering::SeqCst);
|
||||
self.installed.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_officecli_installed(&self) -> bool {
|
||||
self.installed.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct TestBroadcaster {
|
||||
events: std::sync::Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl TestBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: std::sync::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn event_names(&self) -> Vec<String> {
|
||||
self.events.lock().unwrap().iter().map(|e| e.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn event_states(&self) -> Vec<String> {
|
||||
self.events
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|e| e.data["state"].as_str().map(String::from))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for TestBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_temp_file(dir: &tempfile::TempDir, name: &str) -> String {
|
||||
let path = dir.path().join(name);
|
||||
std::fs::write(&path, b"test content").unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WP-2: Session reuse (same file, same doc type)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn wp2_session_reuse_returns_same_port() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner.clone(), broadcaster);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_temp_file(&dir, "doc.docx");
|
||||
|
||||
let p1 = mgr.start(&path, DocType::Word).await.unwrap();
|
||||
let p2 = mgr.start(&path, DocType::Word).await.unwrap();
|
||||
|
||||
assert_eq!(p1, p2);
|
||||
assert_eq!(spawner.spawn_count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WP-3: Stop removes session and kills process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn wp3_stop_terminates_session() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_temp_file(&dir, "doc.docx");
|
||||
|
||||
let port = mgr.start(&path, DocType::Word).await.unwrap();
|
||||
assert!(mgr.is_active_port(port, DocType::Word));
|
||||
|
||||
mgr.stop(&path, DocType::Word).await;
|
||||
assert!(!mgr.is_active_port(port, DocType::Word));
|
||||
assert_eq!(mgr.active_session_count(), 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WP-4: Auto-install when officecli not found
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn wp4_auto_install_on_not_found() {
|
||||
let spawner = Arc::new(TestSpawner::new(false));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner.clone(), broadcaster.clone());
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_temp_file(&dir, "doc.docx");
|
||||
|
||||
let port = mgr.start(&path, DocType::Word).await.unwrap();
|
||||
assert!(port > 0);
|
||||
assert_eq!(spawner.install_count.load(Ordering::SeqCst), 1);
|
||||
|
||||
let states = broadcaster.event_states();
|
||||
assert!(states.contains(&"starting".to_string()));
|
||||
assert!(states.contains(&"installing".to_string()));
|
||||
assert!(states.contains(&"ready".to_string()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EP-1: Excel uses independent session pool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn ep1_excel_independent_session_pool() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_temp_file(&dir, "data.xlsx");
|
||||
|
||||
let word_port = mgr.start(&path, DocType::Word).await.unwrap();
|
||||
let excel_port = mgr.start(&path, DocType::Excel).await.unwrap();
|
||||
|
||||
assert_ne!(word_port, excel_port);
|
||||
assert_eq!(mgr.active_session_count(), 2);
|
||||
assert!(mgr.is_active_port(word_port, DocType::Word));
|
||||
assert!(mgr.is_active_port(excel_port, DocType::Excel));
|
||||
assert!(!mgr.is_active_port(word_port, DocType::Excel));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PP-1: PPT uses independent session pool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn pp1_ppt_independent_session_pool() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_temp_file(&dir, "slides.pptx");
|
||||
|
||||
let port = mgr.start(&path, DocType::Ppt).await.unwrap();
|
||||
assert!(port > 0);
|
||||
assert!(mgr.is_active_port(port, DocType::Ppt));
|
||||
assert!(!mgr.is_active_port(port, DocType::Word));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PP-3: PPT triggers background version check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn pp3_ppt_background_version_check() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_temp_file(&dir, "slides.pptx");
|
||||
|
||||
mgr.start(&path, DocType::Ppt).await.unwrap();
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// Version check is fire-and-forget; we just verify it doesn't panic
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status event naming per doc type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_events_use_correct_prefix() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner, broadcaster.clone());
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let f1 = create_temp_file(&dir, "a.docx");
|
||||
let f2 = create_temp_file(&dir, "b.xlsx");
|
||||
let f3 = create_temp_file(&dir, "c.pptx");
|
||||
|
||||
mgr.start(&f1, DocType::Word).await.unwrap();
|
||||
mgr.start(&f2, DocType::Excel).await.unwrap();
|
||||
mgr.start(&f3, DocType::Ppt).await.unwrap();
|
||||
|
||||
let names = broadcaster.event_names();
|
||||
assert!(names.contains(&"word-preview.status".to_string()));
|
||||
assert!(names.contains(&"excel-preview.status".to_string()));
|
||||
assert!(names.contains(&"ppt-preview.status".to_string()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stop_all lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_all_clears_all_sessions() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let f1 = create_temp_file(&dir, "a.docx");
|
||||
let f2 = create_temp_file(&dir, "b.xlsx");
|
||||
let f3 = create_temp_file(&dir, "c.pptx");
|
||||
|
||||
mgr.start(&f1, DocType::Word).await.unwrap();
|
||||
mgr.start(&f2, DocType::Excel).await.unwrap();
|
||||
mgr.start(&f3, DocType::Ppt).await.unwrap();
|
||||
assert_eq!(mgr.active_session_count(), 3);
|
||||
|
||||
mgr.stop_all();
|
||||
assert_eq!(mgr.active_session_count(), 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSRF defense: is_active_port returns false for non-active ports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp2_inactive_port_rejected() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
|
||||
|
||||
assert!(!mgr.is_active_port(8080, DocType::Word));
|
||||
assert!(!mgr.is_active_port(9999, DocType::Ppt));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stop then restart creates new session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_then_restart_creates_new_session() {
|
||||
let spawner = Arc::new(TestSpawner::new(true));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let mgr = OfficecliWatchManager::new(spawner.clone(), broadcaster);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_temp_file(&dir, "doc.docx");
|
||||
|
||||
let p1 = mgr.start(&path, DocType::Word).await.unwrap();
|
||||
mgr.stop(&path, DocType::Word).await;
|
||||
|
||||
let p2 = mgr.start(&path, DocType::Word).await.unwrap();
|
||||
assert_ne!(p1, p2);
|
||||
assert_eq!(spawner.spawn_count.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
Reference in New Issue
Block a user