mod common; use axum::body::Body; use axum::http::{Request, StatusCode}; use serde_json::json; use tower::ServiceExt; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use common::{body_json, build_app_with_noop_opener, json_with_token, setup_and_login}; // --------------------------------------------------------------------------- // Helper: build multipart/form-data body // --------------------------------------------------------------------------- struct MultipartBuilder { boundary: String, parts: Vec, } impl MultipartBuilder { fn new() -> Self { Self { boundary: "----TestBoundary7MA4YWxkTrZu0gW".to_owned(), parts: Vec::new(), } } fn add_text(mut self, name: &str, value: &str) -> Self { self.parts .extend_from_slice(format!("--{}\r\n", self.boundary).as_bytes()); self.parts .extend_from_slice(format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes()); self.parts.extend_from_slice(value.as_bytes()); self.parts.extend_from_slice(b"\r\n"); self } fn add_file(mut self, name: &str, filename: &str, mime: &str, data: &[u8]) -> Self { self.parts .extend_from_slice(format!("--{}\r\n", self.boundary).as_bytes()); self.parts.extend_from_slice( format!("Content-Disposition: form-data; name=\"{name}\"; filename=\"{filename}\"\r\n").as_bytes(), ); self.parts .extend_from_slice(format!("Content-Type: {mime}\r\n\r\n").as_bytes()); self.parts.extend_from_slice(data); self.parts.extend_from_slice(b"\r\n"); self } fn build(mut self) -> (String, Vec) { self.parts .extend_from_slice(format!("--{}--\r\n", self.boundary).as_bytes()); let content_type = format!("multipart/form-data; boundary={}", self.boundary); (content_type, self.parts) } } fn multipart_request(uri: &str, content_type: &str, body: Vec, token: &str, csrf: &str) -> Request { Request::builder() .method("POST") .uri(uri) .header("content-type", content_type) .header("authorization", format!("Bearer {token}")) .header("x-csrf-token", csrf) .header("cookie", format!("nomifun-csrf-token={csrf}")) .body(Body::from(body)) .unwrap() } async fn set_stt_config(app: &mut axum::Router, token: &str, csrf: &str, config: serde_json::Value) { let req = json_with_token( "PUT", "/api/settings/client", json!({ "speechToText": config }), token, csrf, ); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } // =========================================================================== // A. Shell Operations // =========================================================================== // SH-2: open-file — file not found #[tokio::test] async fn sh2_open_file_not_found() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; let req = json_with_token( "POST", "/api/shell/open-file", json!({ "file_path": "/nonexistent/file.txt" }), &token, &csrf, ); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let json = body_json(resp).await; assert_eq!(json["success"], false); } // SH-4: show-item-in-folder — path not found #[tokio::test] async fn sh4_show_item_in_folder_not_found() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; let req = json_with_token( "POST", "/api/shell/show-item-in-folder", json!({ "file_path": "/nonexistent/path" }), &token, &csrf, ); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } // SH-6: open-external — command injection attempt #[tokio::test] async fn sh6_open_external_command_injection() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; let req = json_with_token( "POST", "/api/shell/open-external", json!({ "url": "; rm -rf /" }), &token, &csrf, ); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let json = body_json(resp).await; assert_eq!(json["success"], false); } // SH-7: open-external — disallowed scheme #[tokio::test] async fn sh7_open_external_file_scheme() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; let req = json_with_token( "POST", "/api/shell/open-external", json!({ "url": "file:///etc/passwd" }), &token, &csrf, ); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } // SH-8: check-tool-installed — terminal always true #[tokio::test] async fn sh8_check_tool_terminal() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; let req = json_with_token( "POST", "/api/shell/check-tool-installed", json!({ "tool": "terminal" }), &token, &csrf, ); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json = body_json(resp).await; assert_eq!(json["success"], true); assert_eq!(json["data"]["installed"], true); } // SH-9: check-tool-installed — explorer always true #[tokio::test] async fn sh9_check_tool_explorer() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; let req = json_with_token( "POST", "/api/shell/check-tool-installed", json!({ "tool": "explorer" }), &token, &csrf, ); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json = body_json(resp).await; assert_eq!(json["success"], true); assert_eq!(json["data"]["installed"], true); } // SH-10: check-tool-installed — vscode (result depends on environment) #[tokio::test] async fn sh10_check_tool_vscode() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; let req = json_with_token( "POST", "/api/shell/check-tool-installed", json!({ "tool": "vscode" }), &token, &csrf, ); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json = body_json(resp).await; assert_eq!(json["success"], true); assert!(json["data"]["installed"].is_boolean()); } // SH-12: open-folder-with — directory not found #[tokio::test] async fn sh12_open_folder_with_nonexistent() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; let req = json_with_token( "POST", "/api/shell/open-folder-with", json!({ "folder_path": "/nonexistent/dir", "tool": "explorer" }), &token, &csrf, ); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } // SH-13: open-file — missing filePath #[tokio::test] async fn sh13_open_file_missing_field() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; let req = json_with_token("POST", "/api/shell/open-file", json!({}), &token, &csrf); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } // SH-14: open-external — empty URL #[tokio::test] async fn sh14_open_external_empty_url() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; let req = json_with_token("POST", "/api/shell/open-external", json!({ "url": "" }), &token, &csrf); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } // =========================================================================== // B. Speech-to-Text (STT) // =========================================================================== // ST-3: STT not enabled #[tokio::test] async fn st3_stt_disabled() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; set_stt_config( &mut app, &token, &csrf, json!({ "enabled": false, "provider": "openai" }), ) .await; let (content_type, body) = MultipartBuilder::new() .add_file("file", "test.wav", "audio/wav", b"fake audio data") .add_text("fileName", "test.wav") .add_text("mimeType", "audio/wav") .build(); let req = multipart_request("/api/stt", &content_type, body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let json = body_json(resp).await; assert_eq!(json["code"], "STT_DISABLED"); } // ST-4: STT config not set (treated as disabled) #[tokio::test] async fn st4_stt_config_not_set() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; let (content_type, body) = MultipartBuilder::new() .add_file("file", "test.wav", "audio/wav", b"fake audio data") .add_text("fileName", "test.wav") .add_text("mimeType", "audio/wav") .build(); let req = multipart_request("/api/stt", &content_type, body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let json = body_json(resp).await; assert_eq!(json["code"], "STT_DISABLED"); } // ST-5: OpenAI not configured (missing API key) #[tokio::test] async fn st5_openai_not_configured() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; set_stt_config( &mut app, &token, &csrf, json!({ "enabled": true, "provider": "openai", "openai": { "api_key": "", "model": "whisper-1" } }), ) .await; let (content_type, body) = MultipartBuilder::new() .add_file("file", "test.wav", "audio/wav", b"fake audio data") .add_text("fileName", "test.wav") .add_text("mimeType", "audio/wav") .build(); let req = multipart_request("/api/stt", &content_type, body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let json = body_json(resp).await; assert_eq!(json["code"], "STT_OPENAI_NOT_CONFIGURED"); } // ST-6: Deepgram not configured (missing API key) #[tokio::test] async fn st6_deepgram_not_configured() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; set_stt_config( &mut app, &token, &csrf, json!({ "enabled": true, "provider": "deepgram", "deepgram": { "api_key": "", "model": "nova-2" } }), ) .await; let (content_type, body) = MultipartBuilder::new() .add_file("file", "test.wav", "audio/wav", b"fake audio data") .add_text("fileName", "test.wav") .add_text("mimeType", "audio/wav") .build(); let req = multipart_request("/api/stt", &content_type, body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let json = body_json(resp).await; assert_eq!(json["code"], "STT_DEEPGRAM_NOT_CONFIGURED"); } // ST-7: STT third-party API failure (fake API key → 401) #[tokio::test] async fn st7_stt_api_failure() { let mock_server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/audio/transcriptions")) .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) .mount(&mock_server) .await; let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; set_stt_config( &mut app, &token, &csrf, json!({ "enabled": true, "provider": "openai", "openai": { "api_key": "sk-fake-key", "base_url": mock_server.uri(), "model": "whisper-1" } }), ) .await; let (content_type, body) = MultipartBuilder::new() .add_file("file", "test.wav", "audio/wav", b"fake audio data") .add_text("fileName", "test.wav") .add_text("mimeType", "audio/wav") .build(); let req = multipart_request("/api/stt", &content_type, body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_GATEWAY); let json = body_json(resp).await; assert_eq!(json["code"], "STT_REQUEST_FAILED"); } // ST-8: multipart missing fileName #[tokio::test] async fn st8_multipart_missing_filename() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; set_stt_config( &mut app, &token, &csrf, json!({ "enabled": true, "provider": "openai", "openai": { "api_key": "sk-test", "model": "whisper-1" } }), ) .await; let (content_type, body) = MultipartBuilder::new() .add_file("file", "test.wav", "audio/wav", b"fake audio data") .add_text("mimeType", "audio/wav") .build(); let req = multipart_request("/api/stt", &content_type, body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let json = body_json(resp).await; assert_eq!(json["success"], false); } // ST-9: multipart missing file #[tokio::test] async fn st9_multipart_missing_file() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; set_stt_config( &mut app, &token, &csrf, json!({ "enabled": true, "provider": "openai", "openai": { "api_key": "sk-test", "model": "whisper-1" } }), ) .await; let (content_type, body) = MultipartBuilder::new() .add_text("fileName", "test.wav") .add_text("mimeType", "audio/wav") .build(); let req = multipart_request("/api/stt", &content_type, body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let json = body_json(resp).await; assert_eq!(json["success"], false); } // ST-1: OpenAI transcription success (mocked) #[tokio::test] async fn st1_openai_transcription_success() { let mock_server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/audio/transcriptions")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "text": "hello world" }))) .mount(&mock_server) .await; let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; set_stt_config( &mut app, &token, &csrf, json!({ "enabled": true, "provider": "openai", "openai": { "api_key": "sk-test-key", "base_url": mock_server.uri(), "model": "whisper-1" } }), ) .await; let (content_type, body) = MultipartBuilder::new() .add_file("file", "test.wav", "audio/wav", b"fake audio data") .add_text("fileName", "test.wav") .add_text("mimeType", "audio/wav") .build(); let req = multipart_request("/api/stt", &content_type, body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json = body_json(resp).await; assert_eq!(json["success"], true); assert_eq!(json["data"]["text"], "hello world"); assert_eq!(json["data"]["model"], "whisper-1"); assert_eq!(json["data"]["provider"], "openai"); } // ST-2: Deepgram transcription success (mocked) #[tokio::test] async fn st2_deepgram_transcription_success() { let mock_server = MockServer::start().await; Mock::given(method("POST")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "metadata": { "model_info": { "key": { "name": "nova-2-general" } } }, "results": { "channels": [{ "alternatives": [{ "transcript": "hello from deepgram" }] }] } }))) .mount(&mock_server) .await; let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; set_stt_config( &mut app, &token, &csrf, json!({ "enabled": true, "provider": "deepgram", "deepgram": { "api_key": "dg-test-key", "base_url": mock_server.uri(), "model": "nova-2" } }), ) .await; let (content_type, body) = MultipartBuilder::new() .add_file("file", "test.wav", "audio/wav", b"fake audio data") .add_text("fileName", "test.wav") .add_text("mimeType", "audio/wav") .build(); let req = multipart_request("/api/stt", &content_type, body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json = body_json(resp).await; assert_eq!(json["success"], true); assert_eq!(json["data"]["text"], "hello from deepgram"); assert_eq!(json["data"]["provider"], "deepgram"); } // ST-10: languageHint passed through #[tokio::test] async fn st10_language_hint_passed() { let mock_server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/audio/transcriptions")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "text": "你好世界" }))) .mount(&mock_server) .await; let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; set_stt_config( &mut app, &token, &csrf, json!({ "enabled": true, "provider": "openai", "openai": { "api_key": "sk-test-key", "base_url": mock_server.uri(), "model": "whisper-1" } }), ) .await; let (content_type, body) = MultipartBuilder::new() .add_file("file", "test.wav", "audio/wav", b"fake audio data") .add_text("fileName", "test.wav") .add_text("mimeType", "audio/wav") .add_text("languageHint", "zh") .build(); let req = multipart_request("/api/stt", &content_type, body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json = body_json(resp).await; assert_eq!(json["success"], true); assert_eq!(json["data"]["text"], "你好世界"); } // =========================================================================== // C. Authentication // =========================================================================== // AU-1: unauthenticated shell request rejected #[tokio::test] async fn au1_shell_unauthenticated() { let (app, _services) = build_app_with_noop_opener().await; let req = Request::builder() .method("POST") .uri("/api/shell/open-file") .header("content-type", "application/json") .body(Body::from(r#"{"file_path":"/tmp/test.txt"}"#)) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } // AU-2: unauthenticated STT request rejected #[tokio::test] async fn au2_stt_unauthenticated() { let (app, _services) = build_app_with_noop_opener().await; let (content_type, body) = MultipartBuilder::new() .add_file("file", "test.wav", "audio/wav", b"fake audio") .add_text("fileName", "test.wav") .add_text("mimeType", "audio/wav") .build(); let req = Request::builder() .method("POST") .uri("/api/stt") .header("content-type", content_type) .body(Body::from(body)) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } // M-145: mailto scheme URL positive test #[tokio::test] async fn sh_open_external_mailto_scheme() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; let req = json_with_token( "POST", "/api/shell/open-external", json!({ "url": "mailto:user@example.com" }), &token, &csrf, ); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } // M-147: multipart missing mimeType field #[tokio::test] async fn st_multipart_missing_mimetype() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; set_stt_config( &mut app, &token, &csrf, json!({ "enabled": true, "provider": "openai", "openai": { "api_key": "sk-test", "model": "whisper-1" } }), ) .await; let (content_type, body) = MultipartBuilder::new() .add_file("file", "test.wav", "audio/wav", b"fake audio data") .add_text("fileName", "test.wav") .build(); let req = multipart_request("/api/stt", &content_type, body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let json = body_json(resp).await; assert_eq!(json["success"], false); }