60663a461c
A user could select another local file with the same name as one that already exists in completed storage. The upload would be allowed to start and only hit an existing-file conflict late in the flow, which made the UI look like the file was uploadable. Reject duplicate sanitized names during upload creation so no staging record or chunk transfer starts for a file that cannot be completed. Keep the completion path non-replacing as a second guard by promoting through a no-overwrite file creation path, with a hard-link fast path and copy fallback for custom temp locations. The browser now treats the server's duplicate-name conflict as a terminal row: it disables the action, marks the item visually, and tells the user to rename the file if they want to upload that copy. Test Plan: - just check Refs: none
133 lines
3.8 KiB
Rust
133 lines
3.8 KiB
Rust
use std::{
|
|
net::{Ipv4Addr, SocketAddr},
|
|
path::Path,
|
|
};
|
|
|
|
use axum::{
|
|
body::Body,
|
|
http::{Request, StatusCode, header},
|
|
};
|
|
use http_body_util::BodyExt;
|
|
use serde_json::json;
|
|
use tempfile::TempDir;
|
|
use tower::ServiceExt;
|
|
use upl::{
|
|
app::{AppConfig, build_router},
|
|
model::{CHUNK_SIZE, CreateUploadResponse, UploadMeta},
|
|
};
|
|
|
|
#[tokio::test]
|
|
async fn creates_upload_metadata_on_disk() -> Result<(), Box<dyn std::error::Error>> {
|
|
let temp_dir = TempDir::new()?;
|
|
let app = test_app(temp_dir.path());
|
|
|
|
let response = app
|
|
.oneshot(json_request(
|
|
"/api/uploads",
|
|
&json!({
|
|
"name": "movie:mkv",
|
|
"size": CHUNK_SIZE + 1,
|
|
"last_modified": 1_760_000_000_000_i64
|
|
}),
|
|
)?)
|
|
.await?;
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let response_body = response.into_body().collect().await?.to_bytes();
|
|
let response: CreateUploadResponse = serde_json::from_slice(&response_body)?;
|
|
|
|
assert_eq!(response.chunk_size, CHUNK_SIZE);
|
|
assert_eq!(response.total_chunks, 2);
|
|
assert!(response.completed_chunks.is_empty());
|
|
|
|
let upload_dir = temp_dir.path().join("staging").join(&response.upload_id);
|
|
let meta_path = upload_dir.join("meta.json");
|
|
assert!(upload_dir.join(".upload.tmp").is_file());
|
|
assert!(upload_dir.join("completed").is_dir());
|
|
assert!(temp_dir.path().join("complete").is_dir());
|
|
|
|
let meta: UploadMeta = serde_json::from_slice(&tokio::fs::read(meta_path).await?)?;
|
|
assert_eq!(meta.id, response.upload_id);
|
|
assert_eq!(meta.original_name, "movie:mkv");
|
|
assert_eq!(meta.safe_name, "movie_mkv");
|
|
assert_eq!(meta.total_chunks, 2);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_empty_upload_name() -> Result<(), Box<dyn std::error::Error>> {
|
|
let temp_dir = TempDir::new()?;
|
|
let app = test_app(temp_dir.path());
|
|
|
|
let response = app
|
|
.oneshot(json_request(
|
|
"/api/uploads",
|
|
&json!({
|
|
"name": " ",
|
|
"size": 10,
|
|
"last_modified": 1_760_000_000_000_i64
|
|
}),
|
|
)?)
|
|
.await?;
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_upload_name_that_already_exists() -> Result<(), Box<dyn std::error::Error>> {
|
|
let temp_dir = TempDir::new()?;
|
|
let app = test_app(temp_dir.path());
|
|
let complete_dir = temp_dir.path().join("complete");
|
|
tokio::fs::create_dir_all(&complete_dir).await?;
|
|
tokio::fs::write(complete_dir.join("xyz.foo"), b"original").await?;
|
|
|
|
let response = app
|
|
.oneshot(json_request(
|
|
"/api/uploads",
|
|
&json!({
|
|
"name": "xyz.foo",
|
|
"size": 10,
|
|
"last_modified": 1_760_000_000_000_i64
|
|
}),
|
|
)?)
|
|
.await?;
|
|
|
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
|
|
let body = response.into_body().collect().await?.to_bytes();
|
|
let body: serde_json::Value = serde_json::from_slice(&body)?;
|
|
assert_eq!(body["error"], "file already exists");
|
|
assert_eq!(
|
|
tokio::fs::read(complete_dir.join("xyz.foo")).await?,
|
|
b"original"
|
|
);
|
|
|
|
let mut staging_entries = tokio::fs::read_dir(temp_dir.path().join("staging")).await?;
|
|
assert!(staging_entries.next_entry().await?.is_none());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn test_app(data_dir: &Path) -> axum::Router {
|
|
build_router(&AppConfig::new(
|
|
SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
|
|
concat!(env!("CARGO_MANIFEST_DIR"), "/static"),
|
|
data_dir,
|
|
))
|
|
}
|
|
|
|
fn json_request(
|
|
uri: &str,
|
|
body: &serde_json::Value,
|
|
) -> Result<Request<Body>, Box<dyn std::error::Error>> {
|
|
Ok(Request::builder()
|
|
.method("POST")
|
|
.uri(uri)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(serde_json::to_vec(&body)?))?)
|
|
}
|