284 lines
11 KiB
Rust
284 lines
11 KiB
Rust
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::<usize>().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
|
|
);
|
|
}
|