mockforge_http/
file_server.rs

1//! File serving for MockForge generated files
2//!
3//! This module provides HTTP endpoints to serve generated mock files
4//! from the mock-files directory.
5
6use axum::http::{header, StatusCode};
7use axum::response::{IntoResponse, Response};
8use std::path::PathBuf;
9use tracing::{error, warn};
10
11/// Serve a generated file from the mock-files directory
12pub async fn serve_mock_file(
13    axum::extract::Path(file_path): axum::extract::Path<String>,
14) -> Result<Response, StatusCode> {
15    // Security: Prevent path traversal
16    if file_path.contains("..") || file_path.contains("//") {
17        warn!("Path traversal attempt detected: {}", file_path);
18        return Err(StatusCode::BAD_REQUEST);
19    }
20
21    // Parse the file path: {route_id}/{filename} or {route_id}/{subdir}/{filename}
22    let parts: Vec<&str> = file_path.split('/').filter(|s| !s.is_empty()).collect();
23    if parts.is_empty() {
24        warn!("Invalid file path format: {}", file_path);
25        return Err(StatusCode::BAD_REQUEST);
26    }
27
28    // Get base directory from environment or use default
29    let base_dir =
30        std::env::var("MOCKFORGE_MOCK_FILES_DIR").unwrap_or_else(|_| "mock-files".to_string());
31
32    // Reconstruct the full path preserving subdirectories
33    let full_file_path = PathBuf::from(&base_dir).join(&file_path);
34
35    // Check if file exists
36    if !full_file_path.exists() {
37        warn!("File not found: {:?}", full_file_path);
38        return Err(StatusCode::NOT_FOUND);
39    }
40
41    // Read file content
42    let content = match tokio::fs::read(&full_file_path).await {
43        Ok(content) => content,
44        Err(e) => {
45            error!("Failed to read file {:?}: {}", full_file_path, e);
46            return Err(StatusCode::INTERNAL_SERVER_ERROR);
47        }
48    };
49
50    // Get filename from path for Content-Disposition header
51    let filename = full_file_path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
52
53    // Determine content type from file extension
54    let content_type = full_file_path
55        .extension()
56        .and_then(|ext| ext.to_str())
57        .map(|ext| match ext.to_lowercase().as_str() {
58            "pdf" => "application/pdf",
59            "csv" => "text/csv",
60            "json" => "application/json",
61            "xml" => "application/xml",
62            "txt" => "text/plain",
63            _ => "application/octet-stream",
64        })
65        .unwrap_or("application/octet-stream");
66
67    // Build response with appropriate headers
68    let headers = [
69        (header::CONTENT_TYPE, content_type),
70        (header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{}\"", filename)),
71    ];
72
73    Ok((StatusCode::OK, headers, content).into_response())
74}
75
76/// Create router for file serving endpoints
77pub fn file_serving_router() -> axum::Router {
78    use axum::routing::get;
79
80    axum::Router::new().route("/mock-files/{*path}", get(serve_mock_file))
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use axum::body::Body;
87    use axum::http::Request;
88    use tower::ServiceExt;
89
90    // ==================== Path Traversal Protection Tests ====================
91
92    #[tokio::test]
93    async fn test_serve_mock_file_path_traversal() {
94        // Test path traversal protection
95        use axum::extract::Path;
96        let result = serve_mock_file(Path("../etc/passwd".to_string())).await;
97        assert!(result.is_err());
98        assert_eq!(result.unwrap_err(), StatusCode::BAD_REQUEST);
99    }
100
101    #[tokio::test]
102    async fn test_serve_mock_file_path_traversal_double_slash() {
103        use axum::extract::Path;
104        let result = serve_mock_file(Path("route//file.json".to_string())).await;
105        assert!(result.is_err());
106        assert_eq!(result.unwrap_err(), StatusCode::BAD_REQUEST);
107    }
108
109    #[tokio::test]
110    async fn test_serve_mock_file_path_traversal_nested() {
111        use axum::extract::Path;
112        let result = serve_mock_file(Path("route/../../../etc/passwd".to_string())).await;
113        assert!(result.is_err());
114        assert_eq!(result.unwrap_err(), StatusCode::BAD_REQUEST);
115    }
116
117    #[tokio::test]
118    async fn test_serve_mock_file_path_traversal_middle() {
119        use axum::extract::Path;
120        let result = serve_mock_file(Path("route/sub/../../../file.txt".to_string())).await;
121        assert!(result.is_err());
122        assert_eq!(result.unwrap_err(), StatusCode::BAD_REQUEST);
123    }
124
125    // ==================== Invalid Path Format Tests ====================
126
127    #[tokio::test]
128    async fn test_serve_mock_file_invalid_format() {
129        // Test invalid path format (empty string results in empty parts)
130        use axum::extract::Path;
131        let result = serve_mock_file(Path("".to_string())).await;
132        assert!(result.is_err());
133        assert_eq!(result.unwrap_err(), StatusCode::BAD_REQUEST);
134    }
135
136    #[tokio::test]
137    async fn test_serve_mock_file_only_slashes() {
138        use axum::extract::Path;
139        let result = serve_mock_file(Path("/".to_string())).await;
140        // After filtering empty parts, should be empty
141        assert!(result.is_err());
142        assert_eq!(result.unwrap_err(), StatusCode::BAD_REQUEST);
143    }
144
145    // ==================== File Not Found Tests ====================
146
147    #[tokio::test]
148    async fn test_serve_mock_file_not_found() {
149        use axum::extract::Path;
150        let result = serve_mock_file(Path("nonexistent/file.json".to_string())).await;
151        assert!(result.is_err());
152        assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
153    }
154
155    #[tokio::test]
156    async fn test_serve_mock_file_deep_path_not_found() {
157        use axum::extract::Path;
158        let result = serve_mock_file(Path("route/subdir/deep/file.json".to_string())).await;
159        assert!(result.is_err());
160        assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
161    }
162
163    // ==================== Router Tests ====================
164
165    #[test]
166    fn test_file_serving_router_creation() {
167        let router = file_serving_router();
168        // Router should be created successfully
169        assert!(std::mem::size_of_val(&router) > 0);
170    }
171
172    #[tokio::test]
173    async fn test_router_path_traversal_blocked() {
174        let router = file_serving_router();
175
176        let request =
177            Request::builder().uri("/mock-files/../etc/passwd").body(Body::empty()).unwrap();
178
179        let response = router.oneshot(request).await.unwrap();
180        // Should be blocked by path traversal check
181        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
182    }
183
184    #[tokio::test]
185    async fn test_router_nonexistent_file() {
186        let router = file_serving_router();
187
188        let request = Request::builder()
189            .uri("/mock-files/route123/file.json")
190            .body(Body::empty())
191            .unwrap();
192
193        let response = router.oneshot(request).await.unwrap();
194        // File doesn't exist
195        assert_eq!(response.status(), StatusCode::NOT_FOUND);
196    }
197
198    // ==================== Content Type Detection Tests ====================
199    // These tests verify the content type logic is correct
200
201    #[test]
202    fn test_content_type_detection_logic() {
203        // Test the extension to content-type mapping logic
204        let extensions = vec![
205            ("pdf", "application/pdf"),
206            ("csv", "text/csv"),
207            ("json", "application/json"),
208            ("xml", "application/xml"),
209            ("txt", "text/plain"),
210            ("unknown", "application/octet-stream"),
211            ("bin", "application/octet-stream"),
212        ];
213
214        for (ext, expected) in extensions {
215            let content_type = match ext.to_lowercase().as_str() {
216                "pdf" => "application/pdf",
217                "csv" => "text/csv",
218                "json" => "application/json",
219                "xml" => "application/xml",
220                "txt" => "text/plain",
221                _ => "application/octet-stream",
222            };
223            assert_eq!(content_type, expected, "Extension: {}", ext);
224        }
225    }
226
227    #[test]
228    fn test_content_type_case_insensitive() {
229        // Content type detection should be case insensitive
230        let extensions = vec!["PDF", "Pdf", "pDf", "JSON", "Json", "XML", "Xml"];
231
232        for ext in extensions {
233            let content_type = match ext.to_lowercase().as_str() {
234                "pdf" => "application/pdf",
235                "json" => "application/json",
236                "xml" => "application/xml",
237                _ => "application/octet-stream",
238            };
239            assert_ne!(
240                content_type, "application/octet-stream",
241                "Extension {} should be recognized",
242                ext
243            );
244        }
245    }
246
247    // ==================== PathBuf Construction Tests ====================
248
249    #[test]
250    fn test_path_construction() {
251        let base_dir = "mock-files";
252        let file_path = "route123/data.json";
253        let full_path = PathBuf::from(base_dir).join(file_path);
254
255        assert!(full_path.to_string_lossy().contains("mock-files"));
256        assert!(full_path.to_string_lossy().contains("route123"));
257        assert!(full_path.to_string_lossy().contains("data.json"));
258    }
259
260    #[test]
261    fn test_path_with_subdirectory() {
262        let base_dir = "mock-files";
263        let file_path = "route123/subdir/nested/file.csv";
264        let full_path = PathBuf::from(base_dir).join(file_path);
265
266        assert!(full_path.to_string_lossy().contains("subdir"));
267        assert!(full_path.to_string_lossy().contains("nested"));
268    }
269
270    #[test]
271    fn test_filename_extraction() {
272        let full_path = PathBuf::from("mock-files/route123/data.json");
273        let filename = full_path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
274        assert_eq!(filename, "data.json");
275    }
276
277    #[test]
278    fn test_filename_extraction_nested() {
279        let full_path = PathBuf::from("mock-files/route/sub/deep/report.pdf");
280        let filename = full_path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
281        assert_eq!(filename, "report.pdf");
282    }
283
284    #[test]
285    fn test_extension_extraction() {
286        let paths = vec![
287            ("mock-files/file.pdf", Some("pdf")),
288            ("mock-files/file.JSON", Some("JSON")),
289            ("mock-files/file.tar.gz", Some("gz")),
290            ("mock-files/file", None),
291        ];
292
293        for (path, expected_ext) in paths {
294            let full_path = PathBuf::from(path);
295            let ext = full_path.extension().and_then(|e| e.to_str());
296            assert_eq!(ext, expected_ext, "Path: {}", path);
297        }
298    }
299}