//! 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 file has no listing and nothing below it: 400 "not a folder". for url in [ format!("/api/files/{root_id}?share={token}"), format!("/api/files/{root_id}/subdir?share={token}"), ] { let r = anon.get(&url).await; assert_eq!(r.status, StatusCode::BAD_REQUEST, "{url}"); assert_eq!(r.json()["code"], "err_fs_not_a_dir"); } } #[tokio::test] async fn share_expiry_must_be_rfc3339() { let env = Env::new().await; let admin = env.admin().await; for bad in ["tomorrow", "2030-01-01", "2030-01-01 10:00", ""] { let r = admin .post_json( "/api/shares", &json!({ "root_id": 1, "path": "docs", "writable": false, "expires_at": bad }), ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST, "{bad:?}: {}", r.text()); assert_eq!(r.json()["code"], "err_bad_expires_at"); } // Offsets other than Z are fine. share(&admin, "docs", false, Some("2999-01-01T12:00:00+02:00")).await; assert_eq!( admin .get("/api/shares") .await .json() .as_array() .unwrap() .len(), 1 ); } #[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}&action=mkdir"), &[], 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}&action=mkdir"), &[], 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 ); } #[tokio::test] async fn share_token_is_not_the_creators_identity() { let env = Env::new().await; let admin = env.admin().await; // The admin owns two shares; the visitor holds the token of one. let secret = share(&admin, "src", false, None).await; let s = share(&admin, "docs", false, None).await; let token = s["token"].as_str().unwrap(); let id = s["id"].as_i64().unwrap(); let root_id = s["root_id"].as_i64().unwrap(); let anon = Client::new(env.app.clone()); // Listing shares with a share token must not leak the creator's shares // (which would include every token they own). let r = anon.get(&format!("/api/shares?share={token}")).await; assert!( r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN, "list shares with share token: {} {}", r.status, r.text() ); assert!(!r.text().contains(secret["token"].as_str().unwrap())); // Nor create shares as the creator. let r = anon .post_json( &format!("/api/shares?share={token}"), &json!({ "root_id": root_id, "path": ".", "writable": false, "expires_at": null }), ) .await; assert!(r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN); // Nor delete the creator's shares. let r = anon .delete(&format!("/api/shares/{id}?share={token}")) .await; assert!(r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN); assert_eq!( admin .get("/api/shares") .await .json() .as_array() .unwrap() .len(), 2 ); // Nor reach the admin API. The header form is blocked as well. for path in ["/api/admin/users", "/api/admin/settings"] { let r = anon.get(&format!("{path}?share={token}")).await; assert!( r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN, "{path}: {}", r.status ); let r = anon .raw( axum::http::Method::GET, path, &[("x-share-token", token)], Vec::new(), ) .await; assert!(r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN); } // A real session still manages its own shares, and the share still browses. assert_eq!(admin.get("/api/shares").await.status, StatusCode::OK); assert_eq!( anon.get(&format!("/api/files/{root_id}?share={token}")) .await .status, StatusCode::OK ); } #[tokio::test] async fn read_only_root_cannot_be_shared_writably() { let env = Env::new().await; let admin = env.admin().await; admin .put_json( "/api/admin/settings", &json!({ "allow_writable_shares": true }), ) .await; // Bob sees "docs" read-only and "src" read-write. create_user(&admin, "bob", "bobpass1", &[("docs", "ro"), ("src", "rw")]).await; let bob = login(&env, "bob", "bobpass1").await; let roots = bob.get("/api/auth/me").await.json(); let roots = roots["roots"].as_array().unwrap().clone(); let root_id = |mode: &str| -> i64 { roots.iter().find(|r| r["mode"] == mode).unwrap()["id"] .as_i64() .unwrap() }; // Writable share of the read-only root → 403. let r = bob .post_json( "/api/shares", &json!({ "root_id": root_id("ro"), "path": ".", "writable": true, "expires_at": null }), ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text()); // A read-only share of the same root is fine. let r = bob .post_json( "/api/shares", &json!({ "root_id": root_id("ro"), "path": ".", "writable": false, "expires_at": null }), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(r.json()["writable"], false); // And the read-write root can still be shared writably. let r = bob .post_json( "/api/shares", &json!({ "root_id": root_id("rw"), "path": ".", "writable": true, "expires_at": null }), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(r.json()["writable"], true); }