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