use super::{ http::{Server, request, request_head}, *, }; use std::{ io::{BufRead, BufReader, Read, Write}, net::{TcpListener, TcpStream}, time::{Duration, Instant}, }; fn copy_sources(source: &Path, target: &Path) { std::fs::create_dir_all(target).unwrap(); for entry in std::fs::read_dir(source).unwrap() { let entry = entry.unwrap(); if entry.file_type().unwrap().is_dir() { copy_sources(&entry.path(), &target.join(entry.file_name())); } else { std::fs::copy(entry.path(), target.join(entry.file_name())).unwrap(); } } } fn multipart(bytes: usize, fields: bool) -> String { let mut body = String::new(); if fields { for (name, value) in [("label", "snow"), ("count", "7"), ("active", "true")] { body.push_str(&format!( "--boundary\r\nContent-Disposition: form-data; name={name}\r\n\r\n{value}\r\n" )); } } body.push_str("--boundary\r\nContent-Disposition: form-data; name=file; filename=\"scene;one.bin\"\r\nContent-Type: application/octet-stream\r\n\r\n"); body.push_str(&"x".repeat(bytes)); body.push_str("\r\n--boundary--\r\n"); body } fn wait_until(mut condition: impl FnMut() -> bool) { let deadline = Instant::now() + Duration::from_secs(5); while !condition() { assert!( Instant::now() < deadline, "upload condition did not complete" ); std::thread::sleep(Duration::from_millis(5)); } } #[test] fn automatic_upload_streams_after_auth_and_cleans_failure_timeout_and_disconnect() { let directory = Directory::new(); let root = directory.0.join("source"); copy_sources(&fixture().with_file_name("auth"), &root); for (path, text) in [ ( "app/uploads.dever", include_str!("../../../fixtures/upload/app.dever"), ), ( "api/admin/uploads.dever", include_str!("../../../fixtures/upload/api.dever"), ), ( "port.dever", include_str!("../../../fixtures/upload/port.dever"), ), ( "adapter.dever", include_str!("../../../fixtures/upload/adapter.dever"), ), ] { std::fs::write(root.join("module/demo/account").join(path), text).unwrap(); } let mut project = Project::load(&root).unwrap(); // 临时检出仅隔离夹具写入,诊断源码仍指向稳定文件,允许重复验收复用构建。 for source in &mut project.sources { if let Ok(relative) = source.path.strip_prefix(&root) { let target = match relative.to_str().unwrap() { "module/demo/account/app/uploads.dever" => Some("app"), "module/demo/account/api/admin/uploads.dever" => Some("api"), "module/demo/account/port.dever" => Some("port"), "module/demo/account/adapter.dever" => Some("adapter"), _ => None, }; let path = target.map_or_else( || fixture().with_file_name("auth").join(relative), |name| { fixture() .with_file_name("upload") .join(format!("{name}.dever")) }, ); *source = dever::syntax::source::Source::new(source.id, path, source.text.clone()); } } let program = project.check().unwrap(); let binary = build::build( &program, &project.sources, "main", &directory.0.join("upload"), ) .unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); drop(listener); std::fs::create_dir(directory.0.join("config")).unwrap(); let settings = serde_json::json!({ "http":{"host":"127.0.0.1","port":port,"limits":{"body_bytes":1024,"timeout_ms":1200},"upload":{"total_bytes":131072,"file_bytes":65536,"field_bytes":256}}, "sites":{"admin":{"path":"admin","auth":"session","hosts":["fixture.test"],"origin":"https://fixture.test"}}, "database":{"default":{"type":"sqlite","path":"data/account.db","max_connections":4}}, "runtime":{"mode":"api","shutdown_ms":1000}, "auth":{"providers":{"session":{"verify":"demo.account.verify","jwtSecret":"upload-native-test-key-not-for-deployment","ttlSeconds":60,"cookie":"session"}}} }); std::fs::write( directory.0.join("config/setting.json"), settings.to_string(), ) .unwrap(); let seeded = execute( &binary, "demo.account.seed", r#"{"subject":"user","session":"valid-session"}"#, ); assert!( seeded.status.success(), "{}", String::from_utf8_lossy(&seeded.stderr) ); let mut server = Server(Command::new(&binary).spawn().unwrap()); wait_until(|| { assert!(server.0.try_wait().unwrap().is_none()); TcpStream::connect(("127.0.0.1", port)).is_ok() }); let temporary = directory.0.join("data/tmp"); let count = || std::fs::read_dir(&temporary).map_or(0, |entries| entries.count()); let target = "/demo/account/admin/uploads/inspect"; // 未认证请求不必上传正文即可收到拒绝,也不能创建临时文件。 let mut unauthenticated = TcpStream::connect(("127.0.0.1", port)).unwrap(); unauthenticated .set_read_timeout(Some(Duration::from_secs(3))) .unwrap(); write!(unauthenticated, "POST {target} HTTP/1.1\r\nHost: fixture.test\r\nConnection: close\r\nContent-Type: multipart/form-data; boundary=boundary\r\nContent-Length: 60000\r\n\r\n").unwrap(); let mut response = String::new(); unauthenticated.read_to_string(&mut response).unwrap(); assert!(response.starts_with("HTTP/1.1 401"), "{response}"); assert_eq!(count(), 0); let (status, head, _) = request_head( port, "POST", "/demo/account/admin/session/login", "fixture.test", "Origin: https://fixture.test\r\nContent-Type: application/json\r\n", r#"{"subject":"user","session":"valid-session"}"#, ); assert_eq!(status, 200, "{head}"); let cookie = head .lines() .find_map(|line| line.strip_prefix("set-cookie: ")) .unwrap() .split(';') .next() .unwrap(); let headers = format!( "Cookie: {cookie}\r\nOrigin: https://fixture.test\r\nContent-Type: multipart/form-data; boundary=boundary\r\n" ); let input = multipart(50_000, true); let (status, body) = request(port, "POST", target, "fixture.test", &headers, &input); assert_eq!(status, 200, "{body}"); let value: serde_json::Value = serde_json::from_str(&body).unwrap(); assert_eq!( value["data"], serde_json::json!({"size":50000,"filename":"scene;one.bin","echo":"snow","increment":8,"enabled":true}) ); assert_eq!(count(), 0); let (status, body) = request( port, "POST", "/demo/account/admin/uploads/store", "fixture.test", &headers, &multipart(50_000, false), ); assert_eq!(status, 200, "{body}"); let stored: serde_json::Value = serde_json::from_str(&body).unwrap(); let key = stored["data"].as_str().unwrap(); assert_eq!( std::fs::read(directory.0.join("data/upload").join(key)).unwrap(), vec![b'x'; 50_000] ); assert_eq!(count(), 0); let mut connection = BufReader::new(TcpStream::connect(("127.0.0.1", port)).unwrap()); connection .get_mut() .set_read_timeout(Some(Duration::from_secs(4))) .unwrap(); let json_headers = format!( "Cookie: {cookie}\r\nOrigin: https://fixture.test\r\nContent-Type: application/json\r\n" ); for (path, headers, body) in [ (target, headers.as_str(), input.as_str()), ( "/demo/account/admin/session/me", json_headers.as_str(), "{}", ), (target, headers.as_str(), input.as_str()), ] { write!(connection.get_mut(), "POST {path} HTTP/1.1\r\nHost: fixture.test\r\nContent-Length: {}\r\n{headers}\r\n{body}", body.len()).unwrap(); let mut status = String::new(); connection.read_line(&mut status).unwrap(); assert!(status.starts_with("HTTP/1.1 200"), "{status}"); let mut length = None; loop { let mut line = String::new(); connection.read_line(&mut line).unwrap(); if line == "\r\n" { break; } if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { length = Some(value.trim().parse::().unwrap()); } } let mut output = vec![0; length.unwrap()]; connection.read_exact(&mut output).unwrap(); assert_eq!(count(), 0); } drop(connection); for (path, input, expected) in [ ( "/demo/account/admin/uploads/reject", multipart(32_768, false), 409, ), (target, multipart(70_000, true), 413), (target, input.replace("name=count", "name=unknown"), 400), ( target, multipart(10, true).replace("\r\n7\r\n", "\r\nwrong\r\n"), 400, ), ( target, multipart(10, true).replace("scene;one.bin", "../escape"), 400, ), ] { let (status, body) = request(port, "POST", path, "fixture.test", &headers, &input); assert_eq!(status, expected, "{body}"); assert_eq!(count(), 0); } // 正在接收文件时分别断开与超时,等待真实文件清理后才确认返回。 for disconnect in [true, false] { let mut socket = TcpStream::connect(("127.0.0.1", port)).unwrap(); socket .set_read_timeout(Some(Duration::from_secs(4))) .unwrap(); let mut input = multipart(32_768, true); input.truncate(input.len() - 16); write!(socket, "POST {target} HTTP/1.1\r\nHost: fixture.test\r\nConnection: close\r\nContent-Length: 60000\r\n{headers}\r\n{input}").unwrap(); wait_until(|| count() == 1); if !disconnect { let mut response = String::new(); socket.read_to_string(&mut response).unwrap(); assert!(response.starts_with("HTTP/1.1 504"), "{response}"); } drop(socket); wait_until(|| count() == 0); } // 同一服务中的旧 JSON/REST 入口仍通过原有有界读取。 assert_eq!( request( port, "GET", "/demo/account/admin/session/health", "fixture.test", "", "" ) .0, 200 ); }