api_shares.rs
⎇
Raw
1//! Token-based shares: creation (incl. writable gating), public resolve,
2//! anonymous access via `?share=`, expiry, scoping, deletion.
3
4mod common;
5
6use axum::http::StatusCode;
7use common::*;
8use serde_json::json;
9
10/// Create a share via the admin (who has the whole root).
11async fn share(admin: &Client, path: &str, writable: bool, expires_at: Option<&str>) -> serde_json::Value {
12 let r = admin
13 .post_json(
14 "/api/shares",
15 &json!({
16 "root_id": 1,
17 "path": path,
18 "writable": writable,
19 "expires_at": expires_at,
20 }),
21 )
22 .await;
23 assert_eq!(r.status, StatusCode::OK, "create share: {}", r.text());
24 r.json()
25}
26
27#[tokio::test]
28async fn create_list_and_delete_shares() {
29 let env = Env::new().await;
30 let admin = env.admin().await;
31
32 let s = share(&admin, "docs", false, None).await;
33 assert_eq!(s["is_file"], false);
34 assert_eq!(s["writable"], false);
35 assert_eq!(s["target"], "docs");
36 let token = s["token"].as_str().unwrap();
37 assert_eq!(token.len(), 32);
38 let root_id = s["root_id"].as_i64().unwrap();
39
40 // Shows up in the list.
41 let r = admin.get("/api/shares").await;
42 let j = r.json();
43 let list = j.as_array().unwrap();
44 assert_eq!(list.len(), 1);
45 assert_eq!(list[0]["id"], s["id"]);
46
47 // Delete by id works for the owner.
48 let id = s["id"].as_i64().unwrap();
49 let r = admin.delete(&format!("/api/shares/{id}")).await;
50 assert_eq!(r.status, StatusCode::OK);
51 // The token is now dead.
52 let r = admin.get(&format!("/api/share/{token}")).await;
53 assert_eq!(r.status, StatusCode::NOT_FOUND);
54 // Anonymous access dies too.
55 let anon = Client::new(env.app.clone());
56 let r = anon.get(&format!("/api/files/{root_id}?share={token}")).await;
57 assert_eq!(r.status, StatusCode::NOT_FOUND);
58
59 // Deleting an unknown id → 404.
60 let r = admin.delete("/api/shares/99999").await;
61 assert_eq!(r.status, StatusCode::NOT_FOUND);
62}
63
64#[tokio::test]
65async fn share_of_a_single_file() {
66 let env = Env::new().await;
67 let admin = env.admin().await;
68 let s = share(&admin, "notes.md", false, None).await;
69 assert_eq!(s["is_file"], true);
70 let token = s["token"].as_str().unwrap();
71 let root_id = s["root_id"].as_i64().unwrap();
72
73 // Anonymous: the share root *is* the file — download it without any
74 // session cookie.
75 let anon = Client::new(env.app.clone());
76 let r = anon
77 .get(&format!("/api/files/{root_id}?share={token}&action=download"))
78 .await;
79 assert_eq!(r.status, StatusCode::OK);
80 assert_eq!(r.body, b"# notes");
81
82 // Preview and content work too.
83 let r = anon.get(&format!("/api/files/{root_id}?share={token}&action=content")).await;
84 assert_eq!(r.body, b"# notes");
85 assert!(r.header("x-file-mtime").is_some());
86
87 // A path *below* the file share doesn't exist.
88 let r = anon
89 .get(&format!("/api/files/{root_id}/subdir?share={token}"))
90 .await;
91 assert!(
92 matches!(r.status, StatusCode::NOT_FOUND | StatusCode::BAD_REQUEST)
93 );
94}
95
96#[tokio::test]
97async fn share_folder_browsing_and_download_anonymously() {
98 let env = Env::new().await;
99 let admin = env.admin().await;
100 let s = share(&admin, "docs", false, None).await;
101 let token = s["token"].as_str().unwrap();
102 let root_id = s["root_id"].as_i64().unwrap();
103
104 let anon = Client::new(env.app.clone());
105 // List the share root.
106 let r = anon.get(&format!("/api/files/{root_id}?share={token}")).await;
107 assert_eq!(r.status, StatusCode::OK);
108 let j = r.json();
109 let names: Vec<&str> = j
110 .get("entries")
111 .unwrap()
112 .as_array()
113 .unwrap()
114 .iter()
115 .map(|e| e["name"].as_str().unwrap())
116 .collect();
117 assert_eq!(names, vec!["inner", "a.txt"]);
118
119 // Navigate into a subfolder via the token.
120 let r = anon.get(&format!("/api/files/{root_id}/inner?share={token}")).await;
121 assert_eq!(r.status, StatusCode::OK);
122 assert_eq!(
123 r.json()["entries"][0]["name"],
124 "hello.txt"
125 );
126
127 // Folder download via the token.
128 let r = anon
129 .get(&format!(
130 "/api/files/{root_id}?share={token}&action=download&format=zip"
131 ))
132 .await;
133 assert_eq!(r.status, StatusCode::OK);
134 let map = zip_map(&r.body);
135 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
136
137 // The token also works as a header.
138 let r = anon
139 .raw(
140 axum::http::Method::GET,
141 &format!("/api/files/{root_id}/a.txt?action=download"),
142 &[("x-share-token", token)],
143 Vec::new(),
144 )
145 .await;
146 assert_eq!(r.status, StatusCode::OK);
147 assert_eq!(r.body, b"file a");
148}
149
150#[tokio::test]
151async fn share_scopes_a_signed_in_user() {
152 let env = Env::new().await;
153 let admin = env.admin().await;
154 // Two shares so the share's synthetic root id (the share row id) is
155 // *different* from the admin's real root id (1).
156 let _distractor = share(&admin, "docs", false, None).await;
157 let s = share(&admin, "docs", false, None).await;
158 let token = s["token"].as_str().unwrap();
159 let share_root_id = s["root_id"].as_i64().unwrap();
160 assert_ne!(share_root_id, 1);
161
162 // A signed-in user who opens the share link sees the share scope.
163 let r = admin
164 .get(&format!("/api/files/{share_root_id}?share={token}"))
165 .await;
166 assert_eq!(r.status, StatusCode::OK);
167 // Without the token the same session sees their own roots.
168 let r = admin.get("/api/files/1").await;
169 assert_eq!(r.status, StatusCode::OK);
170 // …but through the share scope their own root id (1) is unreachable.
171 let r = admin
172 .get(&format!("/api/files/1?share={token}"))
173 .await;
174 assert_eq!(r.status, StatusCode::FORBIDDEN, "share scope hides own roots");
175}
176
177#[tokio::test]
178async fn read_only_share_blocks_writes_writable_share_allows() {
179 let env = Env::new().await;
180 let admin = env.admin().await;
181 admin
182 .put_json(
183 "/api/admin/settings",
184 &json!({ "allow_writable_shares": true }),
185 )
186 .await;
187
188 // Writable shares are gated behind the admin setting.
189 let ro = share(&admin, "docs", false, None).await;
190 let rw = share(&admin, "src", true, None).await;
191 assert_eq!(rw["writable"], true);
192
193 let anon = Client::new(env.app.clone());
194
195 // RO share: anonymous write → 403.
196 let ro_tok = ro["token"].as_str().unwrap();
197 let ro_id = ro["root_id"].as_i64().unwrap();
198 let r = anon
199 .raw(
200 axum::http::Method::POST,
201 &format!("/api/files/{ro_id}/x?share={ro_tok}"),
202 &[],
203 Vec::new(),
204 )
205 .await;
206 assert_eq!(r.status, StatusCode::FORBIDDEN);
207
208 // RW share: anonymous mkdir works (really on disk).
209 let rw_tok = rw["token"].as_str().unwrap();
210 let rw_id = rw["root_id"].as_i64().unwrap();
211 let r = anon
212 .raw(
213 axum::http::Method::POST,
214 &format!("/api/files/{rw_id}/made-by-share?share={rw_tok}"),
215 &[],
216 Vec::new(),
217 )
218 .await;
219 assert_eq!(r.status, StatusCode::OK);
220 assert!(env.file("src/made-by-share").is_dir());
221
222 // RW share: upload works.
223 let up_path = format!("/api/files/{rw_id}?share={rw_tok}");
224 let r = anon
225 .post_multipart(&up_path, &[("uploaded.txt", b"share-up")], "")
226 .await;
227 assert_eq!(r.status, StatusCode::OK);
228 assert_eq!(std::fs::read(env.file("src/uploaded.txt")).unwrap(), b"share-up");
229}
230
231#[tokio::test]
232async fn writable_shares_gated_by_setting() {
233 let env = Env::new().await;
234 let admin = env.admin().await;
235
236 // Default: off.
237 let r = admin
238 .post_json(
239 "/api/shares",
240 &json!({ "root_id": 1, "path": "docs", "writable": true, "expires_at": null }),
241 )
242 .await;
243 assert_eq!(r.status, StatusCode::FORBIDDEN);
244
245 // Enable, retry → ok.
246 let r = admin
247 .put_json("/api/admin/settings", &json!({ "allow_writable_shares": true }))
248 .await;
249 assert_eq!(r.status, StatusCode::OK);
250 let r = admin
251 .post_json(
252 "/api/shares",
253 &json!({ "root_id": 1, "path": "docs", "writable": true, "expires_at": null }),
254 )
255 .await;
256 assert_eq!(r.status, StatusCode::OK);
257 assert_eq!(r.json()["writable"], true);
258}
259
260#[tokio::test]
261async fn share_expiry() {
262 let env = Env::new().await;
263 let admin = env.admin().await;
264
265 // Past expiry → 410 on resolve and on file access.
266 let s = share(&admin, "docs", false, Some("2000-01-01T00:00:00Z")).await;
267 let token = s["token"].as_str().unwrap();
268 let root_id = s["root_id"].as_i64().unwrap();
269 let anon = Client::new(env.app.clone());
270 assert_eq!(
271 admin.get(&format!("/api/share/{token}")).await.status,
272 StatusCode::GONE
273 );
274 assert_eq!(
275 anon.get(&format!("/api/files/{root_id}?share={token}")).await.status,
276 StatusCode::GONE
277 );
278
279 // Far-future expiry → live.
280 let s = share(&admin, "docs", false, Some("2999-01-01T00:00:00Z")).await;
281 let token = s["token"].as_str().unwrap();
282 let root_id = s["root_id"].as_i64().unwrap();
283 assert_eq!(
284 admin.get(&format!("/api/share/{token}")).await.status,
285 StatusCode::OK
286 );
287 assert_eq!(
288 anon.get(&format!("/api/files/{root_id}?share={token}")).await.status,
289 StatusCode::OK
290 );
291}
292
293#[tokio::test]
294async fn share_target_validation() {
295 let env = Env::new().await;
296 let admin = env.admin().await;
297
298 // Missing target → 404.
299 let r = admin
300 .post_json(
301 "/api/shares",
302 &json!({ "root_id": 1, "path": "no-such-folder", "writable": false, "expires_at": null }),
303 )
304 .await;
305 assert_eq!(r.status, StatusCode::NOT_FOUND);
306
307 // Escaping the root → 403.
308 let r = admin
309 .post_json(
310 "/api/shares",
311 &json!({ "root_id": 1, "path": "../../etc", "writable": false, "expires_at": null }),
312 )
313 .await;
314 assert_eq!(r.status, StatusCode::FORBIDDEN);
315
316 // Foreign root id → 403.
317 let r = admin
318 .post_json(
319 "/api/shares",
320 &json!({ "root_id": 999, "path": "docs", "writable": false, "expires_at": null }),
321 )
322 .await;
323 assert_eq!(r.status, StatusCode::FORBIDDEN);
324
325 // Public resolve of a garbage token → 404.
326 let r = admin.get("/api/share/doesnotexist1234567890abcdef1234567890abcdef1234567890abcdef").await;
327 assert_eq!(r.status, StatusCode::NOT_FOUND);
328
329 // Share management requires auth.
330 let anon = Client::new(env.app.clone());
331 assert_eq!(anon.get("/api/shares").await.status, StatusCode::UNAUTHORIZED);
332}
333