//! 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); } /// A share names a path, and a delete, rename or move frees that path. The /// share must die with it, otherwise a *new* item that later takes the freed /// name inherits the old link's audience. #[tokio::test] async fn mutating_a_target_revokes_its_shares() { let env = Env::new().await; let admin = env.admin().await; let files = |rel: &str| format!("/api/files/1/{rel}"); let alive = async |token: &str| { admin.get(&format!("/api/share/{token}")).await.status == StatusCode::OK }; // 1. Delete revokes the share on the deleted item *and* on its children. let parent = share(&admin, "docs", false, None).await; let child = share(&admin, "docs/a.txt", false, None).await; let bystander = share(&admin, "src/main.rs", false, None).await; let (parent, child, bystander) = ( parent["token"].as_str().unwrap().to_string(), child["token"].as_str().unwrap().to_string(), bystander["token"].as_str().unwrap().to_string(), ); assert_eq!(admin.delete(&files("docs")).await.status, StatusCode::OK); assert!(!alive(&parent).await, "share on the deleted folder"); assert!(!alive(&child).await, "share on a file inside it"); assert!(alive(&bystander).await, "an unrelated share must survive"); // 2. Rename frees the old name, so the share on it goes too. The proof // that this matters: a new file takes the freed name right after. let renamed = share(&admin, "notes.md", false, None).await; let renamed = renamed["token"].as_str().unwrap().to_string(); let r = admin .post_json( &files("notes.md"), &json!({"op": "rename", "new_name": "notes-old.md"}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert!(!alive(&renamed).await, "share on the vacated name"); std::fs::write(env.file("notes.md"), "a different file entirely").unwrap(); assert!( !alive(&renamed).await, "the old link must not pick up the new file at that path" ); // 3. Move frees the source path the same way. let moved = share(&admin, "config.json", false, None).await; let moved = moved["token"].as_str().unwrap().to_string(); let r = admin .post_json( &files("config.json"), &json!({"op": "move", "dst_root_id": 1, "dst": "src"}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert!(!alive(&moved).await, "share on the vacated source path"); // 4. A copy frees nothing, so it revokes nothing. let copied = share(&admin, "editme.txt", false, None).await; let copied = copied["token"].as_str().unwrap().to_string(); let r = admin .post_json( &files("editme.txt"), &json!({"op": "copy", "dst_root_id": 1, "dst": "src"}), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert!(alive(&copied).await, "a copy must leave the share alone"); } /// A password must gate the files, not only the share page. The file API /// with `?share=` never goes through the resolve endpoint. #[tokio::test] async fn a_password_locks_the_share_and_its_files() { let env = Env::new().await; let admin = env.admin().await; let r = admin .post_json( "/api/shares", &json!({"root_id": 1, "path": "docs", "password": "hunter22"}), ) .await; assert_eq!(r.status, StatusCode::OK, "create: {}", r.text()); let s = r.json(); assert_eq!(s["has_password"], true); let token = s["token"].as_str().unwrap().to_string(); let root_id = s["root_id"].as_i64().unwrap(); let anon = Client::new(env.app.clone()); // The share page is locked, with its own status so the client can tell // it apart from a dead link. let r = anon.get(&format!("/api/share/{token}")).await; assert_eq!(r.status, StatusCode::UNAUTHORIZED); assert_eq!(r.json()["code"], "err_share_locked"); // ... and so is every file call carrying the token. let r = anon .get(&format!("/api/files/{root_id}?share={token}")) .await; assert_eq!(r.status, StatusCode::UNAUTHORIZED, "listing was not gated"); let r = anon .get(&format!( "/api/files/{root_id}/a.txt?share={token}&action=download" )) .await; assert_eq!(r.status, StatusCode::UNAUTHORIZED, "download was not gated"); // Wrong password: no cookie, still locked. let r = anon .post_json( &format!("/api/share/{token}/unlock"), &json!({"password": "wrong-one"}), ) .await; assert_eq!(r.status, StatusCode::UNAUTHORIZED); assert!(r.header("set-cookie").is_none()); // Right password: the unlock cookie comes back. let r = anon .post_json( &format!("/api/share/{token}/unlock"), &json!({"password": "hunter22"}), ) .await; assert_eq!(r.status, StatusCode::OK, "unlock: {}", r.text()); let cookie = r.header("set-cookie").expect("unlock sets a cookie"); let share_id = s["id"].as_i64().unwrap(); assert!(cookie.starts_with(&format!("fbng_share_{share_id}="))); assert!(cookie.contains("HttpOnly")); let value = cookie .split(';') .next() .unwrap() .split_once('=') .unwrap() .1 .to_string(); // With the cookie, both the page and the files open. let hdr: &[(&str, &str)] = &[("cookie", &format!("fbng_share_{share_id}={value}"))]; let r = anon .raw( axum::http::Method::GET, &format!("/api/share/{token}"), hdr, Vec::new(), ) .await; assert_eq!(r.status, StatusCode::OK, "unlocked resolve: {}", r.text()); let r = anon .raw( axum::http::Method::GET, &format!("/api/files/{root_id}?share={token}"), hdr, Vec::new(), ) .await; assert_eq!(r.status, StatusCode::OK, "unlocked listing: {}", r.text()); // An unlock for one share must not open another. let other = admin .post_json( "/api/shares", &json!({"root_id": 1, "path": "docs/a.txt", "password": "hunter22"}), ) .await .json(); let other_token = other["token"].as_str().unwrap(); let other_root = other["root_id"].as_i64().unwrap(); let r = anon .raw( axum::http::Method::GET, &format!("/api/files/{other_root}?share={other_token}"), hdr, Vec::new(), ) .await; assert_eq!(r.status, StatusCode::UNAUTHORIZED, "cookie crossed shares"); } /// A short password is refused, the same as for an account. #[tokio::test] async fn a_share_password_must_be_long_enough() { let env = Env::new().await; let admin = env.admin().await; let r = admin .post_json( "/api/shares", &json!({"root_id": 1, "path": "docs", "password": "short"}), ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); assert_eq!(r.json()["code"], "err_password_short"); // Nothing was created. assert!( admin .get("/api/shares") .await .json() .as_array() .unwrap() .is_empty() ); } /// Unlocking a share that has no password is a client bug, not a way to mint /// a cookie. #[tokio::test] async fn unlocking_an_open_share_is_rejected() { 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 anon = Client::new(env.app.clone()); let r = anon .post_json( &format!("/api/share/{token}/unlock"), &json!({"password": "whatever"}), ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); assert!(r.header("set-cookie").is_none()); } /// A password-protected *file* share must open its viewer after the correct /// password. The unlock response is the only one the client sees in that /// flow, so it has to carry the file's kind just as `resolve` does. #[tokio::test] async fn unlocking_a_file_share_returns_its_kind() { let env = Env::new().await; let admin = env.admin().await; let s = admin .post_json( "/api/shares", &json!({"root_id": 1, "path": "docs/a.txt", "password": "hunter22"}), ) .await .json(); assert_eq!(s["is_file"], true); let token = s["token"].as_str().unwrap(); let anon = Client::new(env.app.clone()); let r = anon .post_json( &format!("/api/share/{token}/unlock"), &json!({"password": "hunter22"}), ) .await; assert_eq!(r.status, StatusCode::OK, "unlock: {}", r.text()); assert_eq!( r.json()["kind"], "text", "unlock dropped the kind, so the share page would render nothing" ); }