//! File API: listing, download/preview/content, editor save, mutations, //! upload, access control and path-safety. mod common; use axum::http::StatusCode; use common::*; use serde_json::json; /// Root id for the whole-root (".") user root is 1 (first row inserted). const ROOT: i64 = 1; fn root_path(rel: &str) -> String { // No trailing slash for the bare root: axum's routes are // `/api/files/{root_id}` and `/api/files/{root_id}/{*path}`. if rel.is_empty() { format!("/api/files/{ROOT}") } else { format!("/api/files/{ROOT}/{rel}") } } #[tokio::test] async fn list_root_sorted_folders_first() { let env = Env::new().await; let admin = env.admin().await; let r = admin.get(&root_path("")).await; assert_eq!(r.status, StatusCode::OK); let j = r.json(); let entries = j["entries"].as_array().unwrap(); let names: Vec<&str> = entries.iter().map(|e| e["name"].as_str().unwrap()).collect(); assert_eq!( names, vec!["docs", "src", "blob.bin", "config.json", "editme.txt", "notes.md"] ); // Entry fields. let docs = &entries[0]; assert_eq!(docs["is_dir"], true); let editme = entries.iter().find(|e| e["name"] == "editme.txt").unwrap(); assert_eq!(editme["is_dir"], false); assert_eq!(editme["size"], 2); assert!(editme["mtime"].as_str().unwrap().ends_with('Z')); } #[tokio::test] async fn list_subdir_and_errors() { let env = Env::new().await; let admin = env.admin().await; let r = admin.get(&root_path("docs")).await; let j = r.json(); let names: Vec<&str> = j .get("entries") .unwrap() .as_array() .unwrap() .iter() .map(|e| e["name"].as_str().unwrap()) .collect(); assert_eq!(names, vec!["inner", "a.txt"]); // Missing path → 404. assert_eq!( admin.get(&root_path("nope")).await.status, StatusCode::NOT_FOUND ); // Listing a file → 400. assert_eq!( admin.get(&root_path("editme.txt")).await.status, StatusCode::BAD_REQUEST ); // Unknown root id → 403. assert_eq!( admin.get("/api/files/999").await.status, StatusCode::FORBIDDEN ); // No session → 401. let anon = Client::new(env.app.clone()); assert_eq!( anon.get(&root_path("")).await.status, StatusCode::UNAUTHORIZED ); } #[tokio::test] async fn path_traversal_is_blocked() { let env = Env::new().await; let admin = env.admin().await; // Encoded `..` segments reach the handler and are rejected. let r = admin.get("/api/files/1/%2e%2e%2f%2e%2e%2fetc").await; assert!( r.status == StatusCode::FORBIDDEN || r.status == StatusCode::NOT_FOUND, "traversal returned {:?}", r.status ); // Literal `..` segments: must never succeed. let r = admin.get("/api/files/1/../../etc").await; assert_ne!(r.status, StatusCode::OK, "literal traversal must not be served"); // Traversal inside a deeper path. let r = admin.get("/api/files/1/docs/..%2f..%2fsrc").await; assert!( r.status == StatusCode::FORBIDDEN || r.status == StatusCode::NOT_FOUND, "deep traversal returned {:?}", r.status ); } #[tokio::test] async fn download_single_file() { let env = Env::new().await; let admin = env.admin().await; let r = admin.get(&format!("{}?action=download", root_path("editme.txt"))).await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.header("content-disposition").as_deref(), Some("attachment; filename=\"editme.txt\"")); assert_eq!(r.header("content-type").as_deref(), Some("text/plain")); assert_eq!(r.body, b"v1"); // Binary content survives. let r = admin.get(&format!("{}?action=download", root_path("blob.bin"))).await; assert_eq!(r.body, (0..64u8).collect::>()); } #[tokio::test] async fn download_folder_as_all_archive_formats() { let env = Env::new().await; let admin = env.admin().await; let path = format!("{}?action=download", root_path("docs")); let r = admin.get(&format!("{path}&format=zip")).await; assert_eq!(r.status, StatusCode::OK); assert_eq!( r.header("content-type").as_deref(), Some("application/zip") ); assert_eq!( r.header("content-disposition").as_deref(), Some("attachment; filename=\"docs.zip\"") ); let map = zip_map(&r.body); assert_eq!(map.get("docs/a.txt").unwrap(), b"file a"); assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world"); let r = admin.get(&format!("{path}&format=tar")).await; assert_eq!(r.header("content-type").as_deref(), Some("application/x-tar")); assert_eq!(r.header("content-disposition").as_deref(), Some("attachment; filename=\"docs.tar\"")); let map = tar_map(&r.body, Compress::None); assert_eq!(map.get("docs/a.txt").unwrap(), b"file a"); assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world"); let r = admin.get(&format!("{path}&format=tar.gz")).await; assert_eq!(r.header("content-type").as_deref(), Some("application/gzip")); assert_eq!( r.header("content-disposition").as_deref(), Some("attachment; filename=\"docs.tar.gz\"") ); let map = tar_map(&r.body, Compress::Gz); assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world"); let r = admin.get(&format!("{path}&format=tar.zst")).await; assert_eq!(r.header("content-type").as_deref(), Some("application/zstd")); assert_eq!( r.header("content-disposition").as_deref(), Some("attachment; filename=\"docs.tar.zst\"") ); let map = tar_map(&r.body, Compress::Zst); assert_eq!(map.get("docs/a.txt").unwrap(), b"file a"); } #[tokio::test] async fn download_folder_requires_valid_format() { let env = Env::new().await; let admin = env.admin().await; let path = format!("{}?action=download", root_path("docs")); // No format → 400. assert_eq!(admin.get(&path).await.status, StatusCode::BAD_REQUEST); // Unknown format → 400. assert_eq!( admin.get(&format!("{path}&format=rar")).await.status, StatusCode::BAD_REQUEST ); // Downloading a file with a format is fine (format ignored). let r = admin .get(&format!("{}?action=download&format=zip", root_path("editme.txt"))) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.body, b"v1"); } #[tokio::test] async fn preview_serves_inline_and_rejects_dirs() { let env = Env::new().await; let admin = env.admin().await; let r = admin.get(&format!("{}?action=preview", root_path("config.json"))).await; assert_eq!(r.status, StatusCode::OK); assert!(r .header("content-disposition") .unwrap() .starts_with("inline;")); assert_eq!(r.body, b"{\"k\": 1}"); assert_eq!( admin.get(&format!("{}?action=preview", root_path("docs"))).await.status, StatusCode::BAD_REQUEST ); } #[tokio::test] async fn content_action_serves_raw_bytes_with_mtime() { let env = Env::new().await; let admin = env.admin().await; let r = admin.get(&format!("{}?action=content", root_path("notes.md"))).await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.header("content-type").as_deref(), Some("text/plain; charset=utf-8")); let mtime = r.header("x-file-mtime").unwrap(); assert!(mtime.parse::().is_ok()); assert_eq!(r.body, b"# notes"); assert_eq!( admin.get(&format!("{}?action=content", root_path("docs"))).await.status, StatusCode::BAD_REQUEST ); } #[tokio::test] async fn content_is_capped_at_two_mibibytes() { let env = Env::new().await; let admin = env.admin().await; let big = vec![b'x'; 2 * 1024 * 1024 + 1]; std::fs::write(env.file("big.bin"), &big).unwrap(); let r = admin.get(&format!("{}?action=content", root_path("big.bin"))).await; assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE); // The file itself still downloads fine. let r = admin.get(&format!("{}?action=download", root_path("big.bin"))).await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.body.len(), big.len()); } #[tokio::test] async fn editor_save_round_trip_and_conflict() { let env = Env::new().await; let admin = env.admin().await; let path = format!("{}?action=content", root_path("editme.txt")); // Read current mtime via the content endpoint. let r = admin.get(&path).await; assert_eq!(r.status, StatusCode::OK); let mtime: i64 = r.header("x-file-mtime").unwrap().parse().unwrap(); // Save with a matching expected mtime. let r = admin.put_content(&path, b"v2", Some(mtime)).await; assert_eq!(r.status, StatusCode::OK); let new_mtime = r.json()["mtime"].as_i64().unwrap(); assert!(new_mtime >= mtime); assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v2"); // A stale/wrong expected mtime conflicts (409). Use a value far from the // current mtime so this is deterministic regardless of the filesystem's // timestamp granularity (the mtime may not have advanced after the save). let r = admin.put_content(&path, b"v3", Some(mtime + 999_999)).await; assert_eq!(r.status, StatusCode::CONFLICT); // A conflict must not modify the file. assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v2"); // No expected mtime → force save. let r = admin.put_content(&path, b"v4", None).await; assert_eq!(r.status, StatusCode::OK); assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v4"); // Saving a missing file → 404; a directory → 400. // (PUT without action=content → 400.) let r = admin .raw( axum::http::Method::PUT, &root_path("editme.txt"), &[("content-type", "text/plain")], b"x".to_vec(), ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); let r = admin .put_content(&format!("{}?action=content", root_path("ghost.txt")), b"x", None) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); let r = admin .put_content(&format!("{}?action=content", root_path("docs")), b"x", None) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); // Oversized body → 413. let r = admin .put_content(&path, &vec![b'a'; 2 * 1024 * 1024 + 1], None) .await; assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE); } #[tokio::test] async fn mkdir_and_rename() { let env = Env::new().await; let admin = env.admin().await; // mkdir (no content-type → mkdir dispatch). let r = admin.raw(axum::http::Method::POST, &root_path("newdir"), &[], Vec::new()).await; assert_eq!(r.status, StatusCode::OK); assert!(env.file("newdir").is_dir()); // Duplicate → 409. let r = admin.raw(axum::http::Method::POST, &root_path("newdir"), &[], Vec::new()).await; assert_eq!(r.status, StatusCode::CONFLICT); // Empty name → 400 (bare root POST with JSON op is rejected too). let r = admin .raw(axum::http::Method::POST, &root_path(""), &[], Vec::new()) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); // Rename. let r = admin .post_json( &root_path("editme.txt"), &json!({ "op": "rename", "new_name": "renamed.txt" }), ) .await; assert_eq!(r.status, StatusCode::OK); assert!(env.file("renamed.txt").exists()); // Conflict. let r = admin .post_json( &root_path("renamed.txt"), &json!({ "op": "rename", "new_name": "config.json" }), ) .await; assert_eq!(r.status, StatusCode::CONFLICT); // With overwrite. let r = admin .post_json( &root_path("renamed.txt"), &json!({ "op": "rename", "new_name": "config.json", "overwrite": true }), ) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(std::fs::read(env.file("config.json")).unwrap(), b"v1"); // Invalid name. let r = admin .post_json( &root_path("notes.md"), &json!({ "op": "rename", "new_name": "a/b" }), ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); // Missing source. let r = admin .post_json(&root_path("ghost"), &json!({ "op": "rename", "new_name": "x" })) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); // Unknown op. let r = admin .post_json(&root_path("notes.md"), &json!({ "op": "explode" })) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); } #[tokio::test] async fn move_and_copy_across_dirs() { let env = Env::new().await; let admin = env.admin().await; // Move notes.md into docs/. let r = admin .post_json( &root_path("notes.md"), &json!({ "op": "move", "dst_root_id": ROOT, "dst": "docs" }), ) .await; assert_eq!(r.status, StatusCode::OK); assert!(!env.file("notes.md").exists()); assert_eq!(std::fs::read(env.file("docs/notes.md")).unwrap(), b"# notes"); // Copy docs/inner back out — as a folder. let r = admin .post_json( &root_path("docs/inner"), &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }), ) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(std::fs::read(env.file("src/inner/hello.txt")).unwrap(), b"hello world"); assert!(env.file("docs/inner/hello.txt").exists()); // Conflict without overwrite, ok with: copy into a folder that already // holds a file with the same name. let r = admin .post_json( &root_path("docs/a.txt"), &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }), ) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(std::fs::read(env.file("src/a.txt")).unwrap(), b"file a"); std::fs::write(env.file("docs/a.txt"), "file a2").unwrap(); let r = admin .post_json( &root_path("docs/a.txt"), &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }), ) .await; assert_eq!(r.status, StatusCode::CONFLICT); let r = admin .post_json( &root_path("docs/a.txt"), &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src", "overwrite": true }), ) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(std::fs::read(env.file("src/a.txt")).unwrap(), b"file a2"); // Copying an item into its own folder (same path) is a no-op success. let r = admin .post_json( &root_path("docs/a.txt"), &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "docs" }), ) .await; assert_eq!(r.status, StatusCode::OK); // Moving a folder into itself → 400. let r = admin .post_json( &root_path("docs"), &json!({ "op": "move", "dst_root_id": ROOT, "dst": "docs" }), ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); // Missing dst_root_id / dst dir. let r = admin .post_json(&root_path("docs/a.txt"), &json!({ "op": "move" })) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); let r = admin .post_json( &root_path("docs/a.txt"), &json!({ "op": "move", "dst_root_id": ROOT, "dst": "no-such-dir" }), ) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); } #[tokio::test] async fn delete_file_and_folder() { let env = Env::new().await; let admin = env.admin().await; let r = admin.delete(&root_path("editme.txt")).await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.json()["is_dir"], false); assert!(!env.file("editme.txt").exists()); let r = admin.delete(&root_path("docs")).await; assert_eq!(r.json()["is_dir"], true); assert!(!env.file("docs").exists()); // Missing → 404. A DELETE on the bare root matches no route's method → // 405 (the path only has GET/POST routes). assert_eq!( admin.delete(&root_path("ghost")).await.status, StatusCode::NOT_FOUND ); assert_eq!( admin.delete("/api/files/1").await.status, StatusCode::METHOD_NOT_ALLOWED ); // DELETE with a trailing-slash root matches no route at all → 404 via // the SPA fallback's API guard. let r = admin.delete("/api/files/1/").await; assert_eq!(r.status, StatusCode::NOT_FOUND); assert_eq!(r.text(), "unknown endpoint"); } #[tokio::test] async fn upload_creates_files_and_folders() { let env = Env::new().await; let admin = env.admin().await; // Single file into the root, nested part name creates the folder. let r = admin .post_multipart(&root_path(""), &[("docs/uploaded.txt", b"up1"), ("new/nested.txt", b"up2")], "") .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.json()["uploaded"], 2); assert_eq!(std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"up1"); assert_eq!(std::fs::read(env.file("new/nested.txt")).unwrap(), b"up2"); // Conflict: existing file, no overwrite → 409 with the skipped list. let r = admin .post_multipart(&root_path(""), &[("docs/uploaded.txt", b"again")], "") .await; assert_eq!(r.status, StatusCode::CONFLICT); assert_eq!(r.json()["skipped"], json!(["docs/uploaded.txt"])); assert_eq!(std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"up1"); // Mixed: one conflict + one new file → 409, the new one is uploaded. let r = admin .post_multipart( &root_path(""), &[("docs/uploaded.txt", b"again"), ("fresh.txt", b"new")], "", ) .await; assert_eq!(r.status, StatusCode::CONFLICT); assert_eq!(r.json()["skipped"], json!(["docs/uploaded.txt"])); assert_eq!(r.json()["uploaded"], 1); assert_eq!(std::fs::read(env.file("fresh.txt")).unwrap(), b"new"); // overwrite=true replaces. let r = admin .post_multipart(&root_path(""), &[("docs/uploaded.txt", b"v3")], "overwrite=true") .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"v3"); // A part name that is an existing directory → 409. let r = admin .post_multipart(&root_path(""), &[("new", b"dir?")], "") .await; assert_eq!(r.status, StatusCode::CONFLICT); // Path traversal in a part name → 400. let r = admin .post_multipart(&root_path(""), &[("../evil.txt", b"x")], "") .await; assert!(matches!( r.status, StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN )); assert!(!env.file("../evil.txt").exists()); assert!(!env.root.path().parent().unwrap().join("evil.txt").exists()); // No parts at all → 400. let (ct, body) = multipart_body(&[], "b"); let r = admin .raw(axum::http::Method::POST, &root_path(""), &[("content-type", &ct)], body) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); } #[tokio::test] async fn read_only_root_blocks_writes_but_allows_reads() { let env = Env::new().await; let admin = env.admin().await; create_user(&admin, "carol", "carolpass1", &[("docs", "ro")]).await; let carol = login(&env, "carol", "carolpass1").await; let carol_root_id = carol.get("/api/auth/me").await.json()["roots"][0]["id"] .as_i64() .unwrap(); // Reads work. let r = carol.get(&format!("/api/files/{carol_root_id}")).await; assert_eq!(r.status, StatusCode::OK); assert!(!r.json()["entries"].as_array().unwrap().is_empty()); let r = carol .get(&format!( "/api/files/{carol_root_id}/a.txt?action=download" )) .await; assert_eq!(r.body, b"file a"); // Writes are blocked. let base = format!("/api/files/{carol_root_id}/x"); assert_eq!( carol.raw(axum::http::Method::POST, &base, &[], Vec::new()).await.status, StatusCode::FORBIDDEN ); assert_eq!( carol.delete(&format!("/api/files/{carol_root_id}/a.txt")).await.status, StatusCode::FORBIDDEN ); assert_eq!( carol .post_json( &format!("/api/files/{carol_root_id}/a.txt"), &json!({ "op": "rename", "new_name": "b.txt" }) ) .await .status, StatusCode::FORBIDDEN ); } #[tokio::test] async fn user_cannot_touch_foreign_root() { let env = Env::new().await; let admin = env.admin().await; create_user(&admin, "dave", "davepass12", &[("src", "rw")]).await; let dave = login(&env, "dave", "davepass12").await; let dave_root_id = dave.get("/api/auth/me").await.json()["roots"][0]["id"] .as_i64() .unwrap(); // His own root works. assert_eq!( dave.get(&format!("/api/files/{dave_root_id}")).await.status, StatusCode::OK ); // The admin's root id (1) is not his → 403. assert_eq!(dave.get("/api/files/1").await.status, StatusCode::FORBIDDEN); // Writing into a root he doesn't have → 403. assert_eq!( dave .raw(axum::http::Method::POST, "/api/files/1/evil", &[], Vec::new()) .await .status, StatusCode::FORBIDDEN ); }