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(
12 admin: &Client,
13 path: &str,
14 writable: bool,
15 expires_at: Option<&str>,
16) -> serde_json::Value {
17 create_share(
18 admin,
19 json!({
20 "root_id": 1,
21 "path": path,
22 "writable": writable,
23 "expires_at": expires_at,
24 }),
25 )
26 .await
27}
28
29#[tokio::test]
30async fn create_list_and_delete_shares() {
31 let env = Env::new().await;
32 let admin = env.admin().await;
33
34 let s = share(&admin, "docs", false, None).await;
35 assert_eq!(s["is_file"], false);
36 assert_eq!(s["writable"], false);
37 assert_eq!(s["target"], "docs");
38 let token = s["token"].as_str().unwrap();
39 assert_eq!(token.len(), 32);
40 let root_id = s["id"].as_i64().unwrap();
41
42 // Shows up in the list.
43 let r = admin.get("/api/shares").await;
44 let j = r.json();
45 let list = j.as_array().unwrap();
46 assert_eq!(list.len(), 1);
47 assert_eq!(list[0]["id"], s["id"]);
48
49 // Delete by id works for the owner.
50 let id = s["id"].as_i64().unwrap();
51 let r = admin.delete(&format!("/api/shares/{id}")).await;
52 assert_eq!(r.status, StatusCode::OK);
53 // The token is now dead.
54 let r = admin.get(&format!("/api/share/{token}")).await;
55 assert_eq!(r.status, StatusCode::NOT_FOUND);
56 // Anonymous access dies too.
57 let anon = Client::new(env.app.clone());
58 let r = anon
59 .get(&format!("/api/files/{root_id}?share={token}"))
60 .await;
61 assert_eq!(r.status, StatusCode::NOT_FOUND);
62
63 // Deleting an unknown id → 404.
64 let r = admin.delete("/api/shares/99999").await;
65 assert_eq!(r.status, StatusCode::NOT_FOUND);
66}
67
68#[tokio::test]
69async fn share_of_a_single_file() {
70 let env = Env::new().await;
71 let admin = env.admin().await;
72 let s = share(&admin, "notes.md", false, None).await;
73 assert_eq!(s["is_file"], true);
74 let token = s["token"].as_str().unwrap();
75 let root_id = s["id"].as_i64().unwrap();
76
77 // Anonymous: the share root *is* the file — download it without any
78 // session cookie.
79 let anon = Client::new(env.app.clone());
80 let r = anon
81 .get(&format!(
82 "/api/files/{root_id}?share={token}&action=download"
83 ))
84 .await;
85 assert_eq!(r.status, StatusCode::OK);
86 assert_eq!(r.body, b"# notes");
87
88 // Preview and content work too.
89 let r = anon
90 .get(&format!(
91 "/api/files/{root_id}?share={token}&action=content"
92 ))
93 .await;
94 assert_eq!(r.body, b"# notes");
95 assert!(r.header("x-file-mtime").is_some());
96
97 // A file has no listing and nothing below it: 400 "not a folder".
98 for url in [
99 format!("/api/files/{root_id}?share={token}"),
100 format!("/api/files/{root_id}/subdir?share={token}"),
101 ] {
102 let r = anon.get(&url).await;
103 assert_eq!(r.status, StatusCode::BAD_REQUEST, "{url}");
104 assert_eq!(r.json()["code"], "err_fs_not_a_dir");
105 }
106}
107
108#[tokio::test]
109async fn share_expiry_must_be_rfc3339() {
110 let env = Env::new().await;
111 let admin = env.admin().await;
112 for bad in ["tomorrow", "2030-01-01", "2030-01-01 10:00", ""] {
113 let r = admin
114 .post_json(
115 "/api/shares",
116 &json!({ "root_id": 1, "path": "docs", "writable": false, "expires_at": bad }),
117 )
118 .await;
119 assert_eq!(r.status, StatusCode::BAD_REQUEST, "{bad:?}: {}", r.text());
120 assert_eq!(r.json()["code"], "err_bad_expires_at");
121 }
122 // Offsets other than Z are fine.
123 share(&admin, "docs", false, Some("2999-01-01T12:00:00+02:00")).await;
124 assert_eq!(
125 admin
126 .get("/api/shares")
127 .await
128 .json()
129 .as_array()
130 .unwrap()
131 .len(),
132 1
133 );
134}
135
136#[tokio::test]
137async fn share_folder_browsing_and_download_anonymously() {
138 let env = Env::new().await;
139 let admin = env.admin().await;
140 let s = share(&admin, "docs", false, None).await;
141 let token = s["token"].as_str().unwrap();
142 let root_id = s["id"].as_i64().unwrap();
143
144 let anon = Client::new(env.app.clone());
145 // List the share root.
146 let r = anon
147 .get(&format!("/api/files/{root_id}?share={token}"))
148 .await;
149 assert_eq!(r.status, StatusCode::OK);
150 let j = r.json();
151 let names: Vec<&str> = j
152 .get("entries")
153 .unwrap()
154 .as_array()
155 .unwrap()
156 .iter()
157 .map(|e| e["name"].as_str().unwrap())
158 .collect();
159 assert_eq!(names, vec!["inner", "a.txt"]);
160
161 // Navigate into a subfolder via the token.
162 let r = anon
163 .get(&format!("/api/files/{root_id}/inner?share={token}"))
164 .await;
165 assert_eq!(r.status, StatusCode::OK);
166 assert_eq!(r.json()["entries"][0]["name"], "hello.txt");
167
168 // Folder download via the token.
169 let r = anon
170 .get(&format!(
171 "/api/files/{root_id}?share={token}&action=download&format=zip"
172 ))
173 .await;
174 assert_eq!(r.status, StatusCode::OK);
175 let map = zip_map(&r.body);
176 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
177
178 // The token also works as a header.
179 let r = anon
180 .raw(
181 axum::http::Method::GET,
182 &format!("/api/files/{root_id}/a.txt?action=download"),
183 &[("x-share-token", token)],
184 Vec::new(),
185 )
186 .await;
187 assert_eq!(r.status, StatusCode::OK);
188 assert_eq!(r.body, b"file a");
189}
190
191#[tokio::test]
192async fn share_scopes_a_signed_in_user() {
193 let env = Env::new().await;
194 let admin = env.admin().await;
195 // Two shares so the share's synthetic root id (the share row id) is
196 // *different* from the admin's real root id (1).
197 let _distractor = share(&admin, "docs", false, None).await;
198 let s = share(&admin, "docs", false, None).await;
199 let token = s["token"].as_str().unwrap();
200 let share_root_id = s["id"].as_i64().unwrap();
201 assert_ne!(share_root_id, 1);
202
203 // A signed-in user who opens the share link sees the share scope.
204 let r = admin
205 .get(&format!("/api/files/{share_root_id}?share={token}"))
206 .await;
207 assert_eq!(r.status, StatusCode::OK);
208 // Without the token the same session sees their own roots.
209 let r = admin.get("/api/files/1").await;
210 assert_eq!(r.status, StatusCode::OK);
211 // …but through the share scope their own root id (1) is unreachable.
212 let r = admin.get(&format!("/api/files/1?share={token}")).await;
213 assert_eq!(
214 r.status,
215 StatusCode::FORBIDDEN,
216 "share scope hides own roots"
217 );
218}
219
220#[tokio::test]
221async fn read_only_share_blocks_writes_writable_share_allows() {
222 let env = Env::new().await;
223 let admin = env.admin().await;
224 admin
225 .put_json(
226 "/api/admin/settings",
227 &json!({ "allow_writable_shares": true }),
228 )
229 .await;
230
231 // Writable shares are gated behind the admin setting.
232 let ro = share(&admin, "docs", false, None).await;
233 let rw = share(&admin, "src", true, None).await;
234 assert_eq!(rw["writable"], true);
235
236 let anon = Client::new(env.app.clone());
237
238 // RO share: anonymous write → 403.
239 let ro_tok = ro["token"].as_str().unwrap();
240 let ro_id = ro["id"].as_i64().unwrap();
241 let r = anon
242 .raw(
243 axum::http::Method::POST,
244 &format!("/api/files/{ro_id}/x?share={ro_tok}&action=mkdir"),
245 &[],
246 Vec::new(),
247 )
248 .await;
249 assert_eq!(r.status, StatusCode::FORBIDDEN);
250
251 // RW share: anonymous mkdir works (really on disk).
252 let rw_tok = rw["token"].as_str().unwrap();
253 let rw_id = rw["id"].as_i64().unwrap();
254 let r = anon
255 .raw(
256 axum::http::Method::POST,
257 &format!("/api/files/{rw_id}/made-by-share?share={rw_tok}&action=mkdir"),
258 &[],
259 Vec::new(),
260 )
261 .await;
262 assert_eq!(r.status, StatusCode::OK);
263 assert!(env.file("src/made-by-share").is_dir());
264
265 // RW share: upload works.
266 let up_path = format!("/api/files/{rw_id}?share={rw_tok}");
267 let r = anon
268 .post_multipart(&up_path, &[("uploaded.txt", b"share-up")], "")
269 .await;
270 assert_eq!(r.status, StatusCode::OK);
271 assert_eq!(
272 std::fs::read(env.file("src/uploaded.txt")).unwrap(),
273 b"share-up"
274 );
275}
276
277#[tokio::test]
278async fn writable_shares_gated_by_setting() {
279 let env = Env::new().await;
280 let admin = env.admin().await;
281
282 // Default: off.
283 let r = admin
284 .post_json(
285 "/api/shares",
286 &json!({ "root_id": 1, "path": "docs", "writable": true, "expires_at": null }),
287 )
288 .await;
289 assert_eq!(r.status, StatusCode::FORBIDDEN);
290
291 // Enable, retry → ok.
292 let r = admin
293 .put_json(
294 "/api/admin/settings",
295 &json!({ "allow_writable_shares": true }),
296 )
297 .await;
298 assert_eq!(r.status, StatusCode::OK);
299 let r = admin
300 .post_json(
301 "/api/shares",
302 &json!({ "root_id": 1, "path": "docs", "writable": true, "expires_at": null }),
303 )
304 .await;
305 assert_eq!(r.status, StatusCode::OK);
306 assert_eq!(r.json()["writable"], true);
307}
308
309#[tokio::test]
310async fn share_expiry() {
311 let env = Env::new().await;
312 let admin = env.admin().await;
313
314 // Past expiry → 410 on resolve and on file access.
315 let s = share(&admin, "docs", false, Some("2000-01-01T00:00:00Z")).await;
316 let token = s["token"].as_str().unwrap();
317 let root_id = s["id"].as_i64().unwrap();
318 let anon = Client::new(env.app.clone());
319 assert_eq!(
320 admin.get(&format!("/api/share/{token}")).await.status,
321 StatusCode::GONE
322 );
323 assert_eq!(
324 anon.get(&format!("/api/files/{root_id}?share={token}"))
325 .await
326 .status,
327 StatusCode::GONE
328 );
329
330 // Far-future expiry → live.
331 let s = share(&admin, "docs", false, Some("2999-01-01T00:00:00Z")).await;
332 let token = s["token"].as_str().unwrap();
333 let root_id = s["id"].as_i64().unwrap();
334 assert_eq!(
335 admin.get(&format!("/api/share/{token}")).await.status,
336 StatusCode::OK
337 );
338 assert_eq!(
339 anon.get(&format!("/api/files/{root_id}?share={token}"))
340 .await
341 .status,
342 StatusCode::OK
343 );
344}
345
346#[tokio::test]
347async fn share_target_validation() {
348 let env = Env::new().await;
349 let admin = env.admin().await;
350
351 // Missing target → 404.
352 let r = admin
353 .post_json(
354 "/api/shares",
355 &json!({ "root_id": 1, "path": "no-such-folder", "writable": false, "expires_at": null }),
356 )
357 .await;
358 assert_eq!(r.status, StatusCode::NOT_FOUND);
359
360 // Escaping the root → 403.
361 let r = admin
362 .post_json(
363 "/api/shares",
364 &json!({ "root_id": 1, "path": "../../etc", "writable": false, "expires_at": null }),
365 )
366 .await;
367 assert_eq!(r.status, StatusCode::FORBIDDEN);
368
369 // Foreign root id → 403.
370 let r = admin
371 .post_json(
372 "/api/shares",
373 &json!({ "root_id": 999, "path": "docs", "writable": false, "expires_at": null }),
374 )
375 .await;
376 assert_eq!(r.status, StatusCode::FORBIDDEN);
377
378 // Public resolve of a garbage token → 404.
379 let r = admin
380 .get("/api/share/doesnotexist1234567890abcdef1234567890abcdef1234567890abcdef")
381 .await;
382 assert_eq!(r.status, StatusCode::NOT_FOUND);
383
384 // Share management requires auth.
385 let anon = Client::new(env.app.clone());
386 assert_eq!(
387 anon.get("/api/shares").await.status,
388 StatusCode::UNAUTHORIZED
389 );
390}
391
392#[tokio::test]
393async fn share_token_is_not_the_creators_identity() {
394 let env = Env::new().await;
395 let admin = env.admin().await;
396 // The admin owns two shares; the visitor holds the token of one.
397 let secret = share(&admin, "src", false, None).await;
398 let s = share(&admin, "docs", false, None).await;
399 let token = s["token"].as_str().unwrap();
400 let id = s["id"].as_i64().unwrap();
401 let root_id = s["id"].as_i64().unwrap();
402
403 let anon = Client::new(env.app.clone());
404
405 // Listing shares with a share token must not leak the creator's shares
406 // (which would include every token they own).
407 let r = anon.get(&format!("/api/shares?share={token}")).await;
408 assert!(
409 r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN,
410 "list shares with share token: {} {}",
411 r.status,
412 r.text()
413 );
414 assert!(!r.text().contains(secret["token"].as_str().unwrap()));
415
416 // Nor create shares as the creator.
417 let r = anon
418 .post_json(
419 &format!("/api/shares?share={token}"),
420 &json!({ "root_id": root_id, "path": ".", "writable": false, "expires_at": null }),
421 )
422 .await;
423 assert!(r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN);
424
425 // Nor delete the creator's shares.
426 let r = anon
427 .delete(&format!("/api/shares/{id}?share={token}"))
428 .await;
429 assert!(r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN);
430 assert_eq!(
431 admin
432 .get("/api/shares")
433 .await
434 .json()
435 .as_array()
436 .unwrap()
437 .len(),
438 2
439 );
440
441 // Nor reach the admin API. The header form is blocked as well.
442 for path in ["/api/admin/users", "/api/admin/settings"] {
443 let r = anon.get(&format!("{path}?share={token}")).await;
444 assert!(
445 r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN,
446 "{path}: {}",
447 r.status
448 );
449 let r = anon
450 .raw(
451 axum::http::Method::GET,
452 path,
453 &[("x-share-token", token)],
454 Vec::new(),
455 )
456 .await;
457 assert!(r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN);
458 }
459
460 // A real session still manages its own shares, and the share still browses.
461 assert_eq!(admin.get("/api/shares").await.status, StatusCode::OK);
462 assert_eq!(
463 anon.get(&format!("/api/files/{root_id}?share={token}"))
464 .await
465 .status,
466 StatusCode::OK
467 );
468}
469
470#[tokio::test]
471async fn read_only_root_cannot_be_shared_writably() {
472 let env = Env::new().await;
473 let admin = env.admin().await;
474 admin
475 .put_json(
476 "/api/admin/settings",
477 &json!({ "allow_writable_shares": true }),
478 )
479 .await;
480 // Bob sees "docs" read-only and "src" read-write.
481 create_user(&admin, "bob", "bobpass1", &[("docs", "ro"), ("src", "rw")]).await;
482 let bob = login(&env, "bob", "bobpass1").await;
483
484 let roots = bob.get("/api/auth/me").await.json();
485 let roots = roots["roots"].as_array().unwrap().clone();
486 let root_id = |mode: &str| -> i64 {
487 roots.iter().find(|r| r["mode"] == mode).unwrap()["id"]
488 .as_i64()
489 .unwrap()
490 };
491
492 // Writable share of the read-only root → 403.
493 let r = bob
494 .post_json(
495 "/api/shares",
496 &json!({ "root_id": root_id("ro"), "path": ".", "writable": true, "expires_at": null }),
497 )
498 .await;
499 assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
500
501 // A read-only share of the same root is fine.
502 let r = bob
503 .post_json(
504 "/api/shares",
505 &json!({ "root_id": root_id("ro"), "path": ".", "writable": false, "expires_at": null }),
506 )
507 .await;
508 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
509 assert_eq!(r.json()["writable"], false);
510
511 // And the read-write root can still be shared writably.
512 let r = bob
513 .post_json(
514 "/api/shares",
515 &json!({ "root_id": root_id("rw"), "path": ".", "writable": true, "expires_at": null }),
516 )
517 .await;
518 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
519 assert_eq!(r.json()["writable"], true);
520}
521
522/// A share names a path, and a delete, rename or move frees that path. The
523/// share must die with it, otherwise a *new* item that later takes the freed
524/// name inherits the old link's audience.
525#[tokio::test]
526async fn mutating_a_target_revokes_its_shares() {
527 let env = Env::new().await;
528 let admin = env.admin().await;
529 let files = |rel: &str| format!("/api/files/1/{rel}");
530 let alive = async |token: &str| {
531 admin.get(&format!("/api/share/{token}")).await.status == StatusCode::OK
532 };
533
534 // 1. Delete revokes the share on the deleted item *and* on its children.
535 let parent = share(&admin, "docs", false, None).await;
536 let child = share(&admin, "docs/a.txt", false, None).await;
537 let bystander = share(&admin, "src/main.rs", false, None).await;
538 let (parent, child, bystander) = (
539 parent["token"].as_str().unwrap().to_string(),
540 child["token"].as_str().unwrap().to_string(),
541 bystander["token"].as_str().unwrap().to_string(),
542 );
543 assert_eq!(admin.delete(&files("docs")).await.status, StatusCode::OK);
544 assert!(!alive(&parent).await, "share on the deleted folder");
545 assert!(!alive(&child).await, "share on a file inside it");
546 assert!(alive(&bystander).await, "an unrelated share must survive");
547
548 // 2. Rename frees the old name, so the share on it goes too. The proof
549 // that this matters: a new file takes the freed name right after.
550 let renamed = share(&admin, "notes.md", false, None).await;
551 let renamed = renamed["token"].as_str().unwrap().to_string();
552 let r = admin
553 .post_json(
554 &files("notes.md"),
555 &json!({"op": "rename", "new_name": "notes-old.md"}),
556 )
557 .await;
558 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
559 assert!(!alive(&renamed).await, "share on the vacated name");
560
561 std::fs::write(env.file("notes.md"), "a different file entirely").unwrap();
562 assert!(
563 !alive(&renamed).await,
564 "the old link must not pick up the new file at that path"
565 );
566
567 // 3. Move frees the source path the same way.
568 let moved = share(&admin, "config.json", false, None).await;
569 let moved = moved["token"].as_str().unwrap().to_string();
570 let r = admin
571 .post_json(
572 &files("config.json"),
573 &json!({"op": "move", "dst_root_id": 1, "dst": "src"}),
574 )
575 .await;
576 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
577 assert!(!alive(&moved).await, "share on the vacated source path");
578
579 // 4. A copy frees nothing, so it revokes nothing.
580 let copied = share(&admin, "editme.txt", false, None).await;
581 let copied = copied["token"].as_str().unwrap().to_string();
582 let r = admin
583 .post_json(
584 &files("editme.txt"),
585 &json!({"op": "copy", "dst_root_id": 1, "dst": "src"}),
586 )
587 .await;
588 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
589 assert!(alive(&copied).await, "a copy must leave the share alone");
590}
591
592/// A password must gate the files, not only the share page. The file API
593/// with `?share=<token>` never goes through the resolve endpoint.
594#[tokio::test]
595async fn a_password_locks_the_share_and_its_files() {
596 let env = Env::new().await;
597 let admin = env.admin().await;
598
599 let r = admin
600 .post_json(
601 "/api/shares",
602 &json!({"root_id": 1, "path": "docs", "password": "hunter22"}),
603 )
604 .await;
605 assert_eq!(r.status, StatusCode::OK, "create: {}", r.text());
606 let s = r.json();
607 assert_eq!(s["has_password"], true);
608 let token = s["token"].as_str().unwrap().to_string();
609 let root_id = s["id"].as_i64().unwrap();
610
611 let anon = Client::new(env.app.clone());
612
613 // The share page is locked, with its own status so the client can tell
614 // it apart from a dead link.
615 let r = anon.get(&format!("/api/share/{token}")).await;
616 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
617 assert_eq!(r.json()["code"], "err_share_locked");
618
619 // ... and so is every file call carrying the token.
620 let r = anon
621 .get(&format!("/api/files/{root_id}?share={token}"))
622 .await;
623 assert_eq!(r.status, StatusCode::UNAUTHORIZED, "listing was not gated");
624 let r = anon
625 .get(&format!(
626 "/api/files/{root_id}/a.txt?share={token}&action=download"
627 ))
628 .await;
629 assert_eq!(r.status, StatusCode::UNAUTHORIZED, "download was not gated");
630
631 // Wrong password: no cookie, still locked.
632 let r = anon
633 .post_json(
634 &format!("/api/share/{token}/unlock"),
635 &json!({"password": "wrong-one"}),
636 )
637 .await;
638 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
639 assert!(r.header("set-cookie").is_none());
640
641 // Right password: the unlock cookie comes back.
642 let r = anon
643 .post_json(
644 &format!("/api/share/{token}/unlock"),
645 &json!({"password": "hunter22"}),
646 )
647 .await;
648 assert_eq!(r.status, StatusCode::OK, "unlock: {}", r.text());
649 let cookie = r.header("set-cookie").expect("unlock sets a cookie");
650 let share_id = s["id"].as_i64().unwrap();
651 assert!(cookie.starts_with(&format!("fbng_share_{share_id}=")));
652 assert!(cookie.contains("HttpOnly"));
653 let value = cookie
654 .split(';')
655 .next()
656 .unwrap()
657 .split_once('=')
658 .unwrap()
659 .1
660 .to_string();
661
662 // With the cookie, both the page and the files open.
663 let hdr: &[(&str, &str)] = &[("cookie", &format!("fbng_share_{share_id}={value}"))];
664 let r = anon
665 .raw(
666 axum::http::Method::GET,
667 &format!("/api/share/{token}"),
668 hdr,
669 Vec::new(),
670 )
671 .await;
672 assert_eq!(r.status, StatusCode::OK, "unlocked resolve: {}", r.text());
673 let r = anon
674 .raw(
675 axum::http::Method::GET,
676 &format!("/api/files/{root_id}?share={token}"),
677 hdr,
678 Vec::new(),
679 )
680 .await;
681 assert_eq!(r.status, StatusCode::OK, "unlocked listing: {}", r.text());
682
683 // An unlock for one share must not open another.
684 let other = admin
685 .post_json(
686 "/api/shares",
687 &json!({"root_id": 1, "path": "docs/a.txt", "password": "hunter22"}),
688 )
689 .await
690 .json();
691 let other_token = other["token"].as_str().unwrap();
692 let other_root = other["id"].as_i64().unwrap();
693 let r = anon
694 .raw(
695 axum::http::Method::GET,
696 &format!("/api/files/{other_root}?share={other_token}"),
697 hdr,
698 Vec::new(),
699 )
700 .await;
701 assert_eq!(r.status, StatusCode::UNAUTHORIZED, "cookie crossed shares");
702}
703
704/// A short password is refused, the same as for an account.
705#[tokio::test]
706async fn a_share_password_must_be_long_enough() {
707 let env = Env::new().await;
708 let admin = env.admin().await;
709 let r = admin
710 .post_json(
711 "/api/shares",
712 &json!({"root_id": 1, "path": "docs", "password": "short"}),
713 )
714 .await;
715 assert_eq!(r.status, StatusCode::BAD_REQUEST);
716 assert_eq!(r.json()["code"], "err_password_short");
717 // Nothing was created.
718 assert!(
719 admin
720 .get("/api/shares")
721 .await
722 .json()
723 .as_array()
724 .unwrap()
725 .is_empty()
726 );
727}
728
729/// Unlocking a share that has no password is a client bug, not a way to mint
730/// a cookie.
731#[tokio::test]
732async fn unlocking_an_open_share_is_rejected() {
733 let env = Env::new().await;
734 let admin = env.admin().await;
735 let s = share(&admin, "docs", false, None).await;
736 let token = s["token"].as_str().unwrap();
737 let anon = Client::new(env.app.clone());
738 let r = anon
739 .post_json(
740 &format!("/api/share/{token}/unlock"),
741 &json!({"password": "whatever"}),
742 )
743 .await;
744 assert_eq!(r.status, StatusCode::BAD_REQUEST);
745 assert!(r.header("set-cookie").is_none());
746}
747
748/// A password-protected *file* share must open its viewer after the correct
749/// password. The unlock response is the only one the client sees in that
750/// flow, so it has to carry the file's kind just as `resolve` does.
751#[tokio::test]
752async fn unlocking_a_file_share_returns_its_kind() {
753 let env = Env::new().await;
754 let admin = env.admin().await;
755 let s = admin
756 .post_json(
757 "/api/shares",
758 &json!({"root_id": 1, "path": "docs/a.txt", "password": "hunter22"}),
759 )
760 .await
761 .json();
762 assert_eq!(s["is_file"], true);
763 let token = s["token"].as_str().unwrap();
764
765 let anon = Client::new(env.app.clone());
766 let r = anon
767 .post_json(
768 &format!("/api/share/{token}/unlock"),
769 &json!({"password": "hunter22"}),
770 )
771 .await;
772 assert_eq!(r.status, StatusCode::OK, "unlock: {}", r.text());
773 assert_eq!(
774 r.json()["kind"],
775 "text",
776 "unlock dropped the kind, so the share page would render nothing"
777 );
778}
779
780#[tokio::test]
781async fn a_root_can_be_shared_and_keeps_a_name() {
782 let env = Env::new().await;
783 let admin = env.admin().await;
784
785 // An empty path means "the root of this folder". At the top of a root
786 // that is the root itself, which is what the browser's empty-area
787 // "share this folder" sends.
788 let s = share(&admin, "", false, None).await;
789 assert_eq!(s["is_file"], false);
790 // The server root strips to the empty string, which has no last component
791 // to name it. It is stored as "." so the display name falls back to the
792 // configured root name instead of coming back blank.
793 assert_eq!(s["target"], ".");
794 assert_eq!(s["name"], env.state.root_name);
795 assert_ne!(s["name"], "");
796
797 // It resolves and scopes to the whole root.
798 let token = s["token"].as_str().unwrap();
799 let anon = Client::new(env.app.clone());
800 let r = anon.get(&format!("/api/share/{token}")).await;
801 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
802
803 let root_id = s["id"].as_i64().unwrap();
804 let r = anon
805 .get(&format!("/api/files/{root_id}?share={token}"))
806 .await;
807 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
808 let j = r.json();
809 let names: Vec<&str> = j["entries"]
810 .as_array()
811 .unwrap()
812 .iter()
813 .map(|e| e["name"].as_str().unwrap())
814 .collect();
815 assert!(names.contains(&"docs"), "{names:?}");
816 assert!(names.contains(&"notes.md"), "{names:?}");
817}
818
819#[tokio::test]
820async fn a_subfolder_share_by_empty_path_is_unaffected() {
821 let env = Env::new().await;
822 let admin = env.admin().await;
823 create_user(&admin, "sub-root", "subroot1234", &[("docs", "rw")]).await;
824 let user = login(&env, "sub-root", "subroot1234").await;
825
826 // Same call from a user whose root is a subfolder: the target is that
827 // folder, not ".".
828 let r = user
829 .post_json(
830 "/api/shares",
831 &json!({ "root_id": 2, "path": "", "writable": false }),
832 )
833 .await;
834 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
835 let s = r.json();
836 assert_eq!(s["target"], "docs");
837 assert_eq!(s["name"], "docs");
838}
839
840// ---------------------------------------------------------------------------
841// The admin's share view (GET/DELETE /api/admin/shares)
842// ---------------------------------------------------------------------------
843
844/// Create a share as `who`, from their first root.
845async fn share_as(who: &Client, path: &str) -> serde_json::Value {
846 let root_id = who.get("/api/auth/me").await.json()["roots"][0]["id"].clone();
847 create_share(
848 who,
849 json!({"root_id": root_id, "path": path, "writable": false}),
850 )
851 .await
852}
853
854#[tokio::test]
855async fn an_admin_sees_every_share_with_its_creator() {
856 let env = Env::new().await;
857 let admin = env.admin().await;
858 create_user(&admin, "bob", "bobpass123", &[("docs", "rw")]).await;
859 create_user(&admin, "carol", "carolpass123", &[("src", "rw")]).await;
860 let bob = login(&env, "bob", "bobpass123").await;
861 let carol = login(&env, "carol", "carolpass123").await;
862 let bob_share = share_as(&bob, "a.txt").await;
863 share_as(&carol, "").await;
864 let own = share(&admin, "notes.md", false, None).await;
865
866 let r = admin.get("/api/admin/shares").await;
867 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
868 let all = r.json();
869 let rows = all.as_array().unwrap();
870 assert_eq!(rows.len(), 3, "{all}");
871
872 // The real token, not a redacted one: the copy button must produce a link
873 // that works.
874 let row = rows
875 .iter()
876 .find(|s| s["id"] == bob_share["id"])
877 .expect("bob's share in the admin list");
878 assert_eq!(row["creator_name"], "bob");
879 assert!(row["creator_active"].as_bool().unwrap());
880 assert_eq!(row["token"], bob_share["token"]);
881 assert_eq!(row["target"], "docs/a.txt");
882 assert_eq!(row["name"], "a.txt");
883
884 let by_name = |n: &str| {
885 rows.iter()
886 .filter(|s| s["creator_name"] == json!(n))
887 .count()
888 };
889 assert_eq!(by_name("bob"), 1);
890 assert_eq!(by_name("carol"), 1);
891 assert_eq!(by_name("admin"), 1);
892 assert_eq!(
893 rows.iter().find(|s| s["id"] == own["id"]).unwrap()["creator_name"],
894 "admin"
895 );
896
897 // The viewer's own list is unchanged: scoping is per creator, not per
898 // target.
899 let mine = bob.get("/api/shares").await.json();
900 assert_eq!(mine.as_array().unwrap().len(), 1, "{mine}");
901 assert_eq!(mine[0]["id"], bob_share["id"]);
902}
903
904/// A deactivated account keeps its links, and nobody can sign in to end them.
905#[tokio::test]
906async fn an_admin_can_end_a_share_the_creator_can_no_longer_reach() {
907 let env = Env::new().await;
908 let admin = env.admin().await;
909 let created = create_user(&admin, "bob", "bobpass123", &[("docs", "rw")]).await;
910 let bob = login(&env, "bob", "bobpass123").await;
911 let s = share_as(&bob, "a.txt").await;
912 let (id, token) = (s["id"].as_i64().unwrap(), s["token"].as_str().unwrap());
913 let anon = Client::new(env.app.clone());
914 assert_eq!(
915 anon.get(&format!("/api/share/{token}")).await.status,
916 StatusCode::OK,
917 "the link works before anything changes"
918 );
919
920 // Suspend bob and take his folder. The link survives that (a known gap), so
921 // the admin view must flag it and be able to close it.
922 admin
923 .put_json(
924 &format!("/api/admin/users/{}", created["id"]),
925 &json!({ "active": false, "roots": [] }),
926 )
927 .await;
928 let r = admin.get("/api/admin/shares").await.json();
929 let row = r.as_array().unwrap().first().unwrap();
930 assert_eq!(row["creator_name"], "bob");
931 assert!(
932 !row["creator_active"].as_bool().unwrap(),
933 "a deactivated account's live share is flagged: {r}"
934 );
935 assert_eq!(
936 anon.get(&format!("/api/share/{token}")).await.status,
937 StatusCode::OK,
938 "still open until someone ends it"
939 );
940
941 // The admin ends it. bob cannot: he can no longer sign in.
942 let r = admin.delete(&format!("/api/admin/shares/{id}")).await;
943 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
944 assert_eq!(
945 anon.get(&format!("/api/share/{token}")).await.status,
946 StatusCode::NOT_FOUND,
947 "the link is dead"
948 );
949 assert!(
950 admin
951 .get("/api/admin/shares")
952 .await
953 .json()
954 .as_array()
955 .unwrap()
956 .is_empty()
957 );
958
959 // An id that is not a share is a 404, not a silent success.
960 assert_eq!(
961 admin.delete("/api/admin/shares/4242").await.status,
962 StatusCode::NOT_FOUND
963 );
964}
965
966/// The admin listing hands out every token, so a share token must never read
967/// it, even one created by an admin session.
968#[tokio::test]
969async fn a_share_token_cannot_read_the_admin_listing() {
970 let env = Env::new().await;
971 let admin = env.admin().await;
972 let s = share(&admin, "docs", false, None).await;
973 let token = s["token"].as_str().unwrap().to_string();
974 let anon = Client::new(env.app.clone());
975 let r = anon.get(&format!("/api/admin/shares?share={token}")).await;
976 assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
977 assert!(
978 anon.delete(&format!("/api/admin/shares/1?share={token}"))
979 .await
980 .status
981 .is_client_error()
982 );
983 // The token still opens its own share.
984 assert_eq!(
985 anon.get(&format!("/api/share/{token}")).await.status,
986 StatusCode::OK
987 );
988}
989