//! Token-based shares: creation (incl. writable gating), public resolve, //! anonymous access via `?share=`, expiry, scoping, deletion. mod common; use axum::http::StatusCode; use common::*; use serde_json::json; /// Create a share via the admin (who has the whole root). async fn share(admin: &Client, path: &str, writable: bool, expires_at: Option<&str>) -> serde_json::Value { let r = admin .post_json( "/api/shares", &json!({ "root_id": 1, "path": path, "writable": writable, "expires_at": expires_at, }), ) .await; assert_eq!(r.status, StatusCode::OK, "create share: {}", r.text()); r.json() } #[tokio::test] async fn create_list_and_delete_shares() { let env = Env::new().await; let admin = env.admin().await; let s = share(&admin, "docs", false, None).await; assert_eq!(s["is_file"], false); assert_eq!(s["writable"], false); assert_eq!(s["target"], "docs"); let token = s["token"].as_str().unwrap(); assert_eq!(token.len(), 32); let root_id = s["root_id"].as_i64().unwrap(); // Shows up in the list. let r = admin.get("/api/shares").await; let j = r.json(); let list = j.as_array().unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0]["id"], s["id"]); // Delete by id works for the owner. let id = s["id"].as_i64().unwrap(); let r = admin.delete(&format!("/api/shares/{id}")).await; assert_eq!(r.status, StatusCode::OK); // The token is now dead. let r = admin.get(&format!("/api/share/{token}")).await; assert_eq!(r.status, StatusCode::NOT_FOUND); // Anonymous access dies too. let anon = Client::new(env.app.clone()); let r = anon.get(&format!("/api/files/{root_id}?share={token}")).await; assert_eq!(r.status, StatusCode::NOT_FOUND); // Deleting an unknown id → 404. let r = admin.delete("/api/shares/99999").await; assert_eq!(r.status, StatusCode::NOT_FOUND); } #[tokio::test] async fn share_of_a_single_file() { let env = Env::new().await; let admin = env.admin().await; let s = share(&admin, "notes.md", false, None).await; assert_eq!(s["is_file"], true); let token = s["token"].as_str().unwrap(); let root_id = s["root_id"].as_i64().unwrap(); // Anonymous: the share root *is* the file — download it without any // session cookie. let anon = Client::new(env.app.clone()); let r = anon .get(&format!("/api/files/{root_id}?share={token}&action=download")) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.body, b"# notes"); // Preview and content work too. let r = anon.get(&format!("/api/files/{root_id}?share={token}&action=content")).await; assert_eq!(r.body, b"# notes"); assert!(r.header("x-file-mtime").is_some()); // A path *below* the file share doesn't exist. let r = anon .get(&format!("/api/files/{root_id}/subdir?share={token}")) .await; assert!( matches!(r.status, StatusCode::NOT_FOUND | StatusCode::BAD_REQUEST) ); } #[tokio::test] async fn share_folder_browsing_and_download_anonymously() { let env = Env::new().await; let admin = env.admin().await; let s = share(&admin, "docs", false, None).await; let token = s["token"].as_str().unwrap(); let root_id = s["root_id"].as_i64().unwrap(); let anon = Client::new(env.app.clone()); // List the share root. let r = anon.get(&format!("/api/files/{root_id}?share={token}")).await; assert_eq!(r.status, StatusCode::OK); 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"]); // Navigate into a subfolder via the token. let r = anon.get(&format!("/api/files/{root_id}/inner?share={token}")).await; assert_eq!(r.status, StatusCode::OK); assert_eq!( r.json()["entries"][0]["name"], "hello.txt" ); // Folder download via the token. let r = anon .get(&format!( "/api/files/{root_id}?share={token}&action=download&format=zip" )) .await; assert_eq!(r.status, StatusCode::OK); let map = zip_map(&r.body); assert_eq!(map.get("docs/a.txt").unwrap(), b"file a"); // The token also works as a header. let r = anon .raw( axum::http::Method::GET, &format!("/api/files/{root_id}/a.txt?action=download"), &[("x-share-token", token)], Vec::new(), ) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.body, b"file a"); } #[tokio::test] async fn share_scopes_a_signed_in_user() { let env = Env::new().await; let admin = env.admin().await; // Two shares so the share's synthetic root id (the share row id) is // *different* from the admin's real root id (1). let _distractor = share(&admin, "docs", false, None).await; let s = share(&admin, "docs", false, None).await; let token = s["token"].as_str().unwrap(); let share_root_id = s["root_id"].as_i64().unwrap(); assert_ne!(share_root_id, 1); // A signed-in user who opens the share link sees the share scope. let r = admin .get(&format!("/api/files/{share_root_id}?share={token}")) .await; assert_eq!(r.status, StatusCode::OK); // Without the token the same session sees their own roots. let r = admin.get("/api/files/1").await; assert_eq!(r.status, StatusCode::OK); // …but through the share scope their own root id (1) is unreachable. let r = admin .get(&format!("/api/files/1?share={token}")) .await; assert_eq!(r.status, StatusCode::FORBIDDEN, "share scope hides own roots"); } #[tokio::test] async fn read_only_share_blocks_writes_writable_share_allows() { let env = Env::new().await; let admin = env.admin().await; admin .put_json( "/api/admin/settings", &json!({ "allow_writable_shares": true }), ) .await; // Writable shares are gated behind the admin setting. let ro = share(&admin, "docs", false, None).await; let rw = share(&admin, "src", true, None).await; assert_eq!(rw["writable"], true); let anon = Client::new(env.app.clone()); // RO share: anonymous write → 403. let ro_tok = ro["token"].as_str().unwrap(); let ro_id = ro["root_id"].as_i64().unwrap(); let r = anon .raw( axum::http::Method::POST, &format!("/api/files/{ro_id}/x?share={ro_tok}"), &[], Vec::new(), ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); // RW share: anonymous mkdir works (really on disk). let rw_tok = rw["token"].as_str().unwrap(); let rw_id = rw["root_id"].as_i64().unwrap(); let r = anon .raw( axum::http::Method::POST, &format!("/api/files/{rw_id}/made-by-share?share={rw_tok}"), &[], Vec::new(), ) .await; assert_eq!(r.status, StatusCode::OK); assert!(env.file("src/made-by-share").is_dir()); // RW share: upload works. let up_path = format!("/api/files/{rw_id}?share={rw_tok}"); let r = anon .post_multipart(&up_path, &[("uploaded.txt", b"share-up")], "") .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(std::fs::read(env.file("src/uploaded.txt")).unwrap(), b"share-up"); } #[tokio::test] async fn writable_shares_gated_by_setting() { let env = Env::new().await; let admin = env.admin().await; // Default: off. let r = admin .post_json( "/api/shares", &json!({ "root_id": 1, "path": "docs", "writable": true, "expires_at": null }), ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); // Enable, retry → ok. let r = admin .put_json("/api/admin/settings", &json!({ "allow_writable_shares": true })) .await; assert_eq!(r.status, StatusCode::OK); let r = admin .post_json( "/api/shares", &json!({ "root_id": 1, "path": "docs", "writable": true, "expires_at": null }), ) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.json()["writable"], true); } #[tokio::test] async fn share_expiry() { let env = Env::new().await; let admin = env.admin().await; // Past expiry → 410 on resolve and on file access. let s = share(&admin, "docs", false, Some("2000-01-01T00:00:00Z")).await; let token = s["token"].as_str().unwrap(); let root_id = s["root_id"].as_i64().unwrap(); let anon = Client::new(env.app.clone()); assert_eq!( admin.get(&format!("/api/share/{token}")).await.status, StatusCode::GONE ); assert_eq!( anon.get(&format!("/api/files/{root_id}?share={token}")).await.status, StatusCode::GONE ); // Far-future expiry → live. let s = share(&admin, "docs", false, Some("2999-01-01T00:00:00Z")).await; let token = s["token"].as_str().unwrap(); let root_id = s["root_id"].as_i64().unwrap(); assert_eq!( admin.get(&format!("/api/share/{token}")).await.status, StatusCode::OK ); assert_eq!( anon.get(&format!("/api/files/{root_id}?share={token}")).await.status, StatusCode::OK ); } #[tokio::test] async fn share_target_validation() { let env = Env::new().await; let admin = env.admin().await; // Missing target → 404. let r = admin .post_json( "/api/shares", &json!({ "root_id": 1, "path": "no-such-folder", "writable": false, "expires_at": null }), ) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); // Escaping the root → 403. let r = admin .post_json( "/api/shares", &json!({ "root_id": 1, "path": "../../etc", "writable": false, "expires_at": null }), ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); // Foreign root id → 403. let r = admin .post_json( "/api/shares", &json!({ "root_id": 999, "path": "docs", "writable": false, "expires_at": null }), ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); // Public resolve of a garbage token → 404. let r = admin.get("/api/share/doesnotexist1234567890abcdef1234567890abcdef1234567890abcdef").await; assert_eq!(r.status, StatusCode::NOT_FOUND); // Share management requires auth. let anon = Client::new(env.app.clone()); assert_eq!(anon.get("/api/shares").await.status, StatusCode::UNAUTHORIZED); }