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 let r = admin
18 .post_json(
19 "/api/shares",
20 &json!({
21 "root_id": 1,
22 "path": path,
23 "writable": writable,
24 "expires_at": expires_at,
25 }),
26 )
27 .await;
28 assert_eq!(r.status, StatusCode::OK, "create share: {}", r.text());
29 r.json()
30}
31
32#[tokio::test]
33async fn create_list_and_delete_shares() {
34 let env = Env::new().await;
35 let admin = env.admin().await;
36
37 let s = share(&admin, "docs", false, None).await;
38 assert_eq!(s["is_file"], false);
39 assert_eq!(s["writable"], false);
40 assert_eq!(s["target"], "docs");
41 let token = s["token"].as_str().unwrap();
42 assert_eq!(token.len(), 32);
43 let root_id = s["root_id"].as_i64().unwrap();
44
45 // Shows up in the list.
46 let r = admin.get("/api/shares").await;
47 let j = r.json();
48 let list = j.as_array().unwrap();
49 assert_eq!(list.len(), 1);
50 assert_eq!(list[0]["id"], s["id"]);
51
52 // Delete by id works for the owner.
53 let id = s["id"].as_i64().unwrap();
54 let r = admin.delete(&format!("/api/shares/{id}")).await;
55 assert_eq!(r.status, StatusCode::OK);
56 // The token is now dead.
57 let r = admin.get(&format!("/api/share/{token}")).await;
58 assert_eq!(r.status, StatusCode::NOT_FOUND);
59 // Anonymous access dies too.
60 let anon = Client::new(env.app.clone());
61 let r = anon
62 .get(&format!("/api/files/{root_id}?share={token}"))
63 .await;
64 assert_eq!(r.status, StatusCode::NOT_FOUND);
65
66 // Deleting an unknown id → 404.
67 let r = admin.delete("/api/shares/99999").await;
68 assert_eq!(r.status, StatusCode::NOT_FOUND);
69}
70
71#[tokio::test]
72async fn share_of_a_single_file() {
73 let env = Env::new().await;
74 let admin = env.admin().await;
75 let s = share(&admin, "notes.md", false, None).await;
76 assert_eq!(s["is_file"], true);
77 let token = s["token"].as_str().unwrap();
78 let root_id = s["root_id"].as_i64().unwrap();
79
80 // Anonymous: the share root *is* the file — download it without any
81 // session cookie.
82 let anon = Client::new(env.app.clone());
83 let r = anon
84 .get(&format!(
85 "/api/files/{root_id}?share={token}&action=download"
86 ))
87 .await;
88 assert_eq!(r.status, StatusCode::OK);
89 assert_eq!(r.body, b"# notes");
90
91 // Preview and content work too.
92 let r = anon
93 .get(&format!(
94 "/api/files/{root_id}?share={token}&action=content"
95 ))
96 .await;
97 assert_eq!(r.body, b"# notes");
98 assert!(r.header("x-file-mtime").is_some());
99
100 // A file has no listing and nothing below it: 400 "not a folder".
101 for url in [
102 format!("/api/files/{root_id}?share={token}"),
103 format!("/api/files/{root_id}/subdir?share={token}"),
104 ] {
105 let r = anon.get(&url).await;
106 assert_eq!(r.status, StatusCode::BAD_REQUEST, "{url}");
107 assert_eq!(r.json()["code"], "err_fs_not_a_dir");
108 }
109}
110
111#[tokio::test]
112async fn share_expiry_must_be_rfc3339() {
113 let env = Env::new().await;
114 let admin = env.admin().await;
115 for bad in ["tomorrow", "2030-01-01", "2030-01-01 10:00", ""] {
116 let r = admin
117 .post_json(
118 "/api/shares",
119 &json!({ "root_id": 1, "path": "docs", "writable": false, "expires_at": bad }),
120 )
121 .await;
122 assert_eq!(r.status, StatusCode::BAD_REQUEST, "{bad:?}: {}", r.text());
123 assert_eq!(r.json()["code"], "err_bad_expires_at");
124 }
125 // Offsets other than Z are fine.
126 share(&admin, "docs", false, Some("2999-01-01T12:00:00+02:00")).await;
127 assert_eq!(
128 admin
129 .get("/api/shares")
130 .await
131 .json()
132 .as_array()
133 .unwrap()
134 .len(),
135 1
136 );
137}
138
139#[tokio::test]
140async fn share_folder_browsing_and_download_anonymously() {
141 let env = Env::new().await;
142 let admin = env.admin().await;
143 let s = share(&admin, "docs", false, None).await;
144 let token = s["token"].as_str().unwrap();
145 let root_id = s["root_id"].as_i64().unwrap();
146
147 let anon = Client::new(env.app.clone());
148 // List the share root.
149 let r = anon
150 .get(&format!("/api/files/{root_id}?share={token}"))
151 .await;
152 assert_eq!(r.status, StatusCode::OK);
153 let j = r.json();
154 let names: Vec<&str> = j
155 .get("entries")
156 .unwrap()
157 .as_array()
158 .unwrap()
159 .iter()
160 .map(|e| e["name"].as_str().unwrap())
161 .collect();
162 assert_eq!(names, vec!["inner", "a.txt"]);
163
164 // Navigate into a subfolder via the token.
165 let r = anon
166 .get(&format!("/api/files/{root_id}/inner?share={token}"))
167 .await;
168 assert_eq!(r.status, StatusCode::OK);
169 assert_eq!(r.json()["entries"][0]["name"], "hello.txt");
170
171 // Folder download via the token.
172 let r = anon
173 .get(&format!(
174 "/api/files/{root_id}?share={token}&action=download&format=zip"
175 ))
176 .await;
177 assert_eq!(r.status, StatusCode::OK);
178 let map = zip_map(&r.body);
179 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
180
181 // The token also works as a header.
182 let r = anon
183 .raw(
184 axum::http::Method::GET,
185 &format!("/api/files/{root_id}/a.txt?action=download"),
186 &[("x-share-token", token)],
187 Vec::new(),
188 )
189 .await;
190 assert_eq!(r.status, StatusCode::OK);
191 assert_eq!(r.body, b"file a");
192}
193
194#[tokio::test]
195async fn share_scopes_a_signed_in_user() {
196 let env = Env::new().await;
197 let admin = env.admin().await;
198 // Two shares so the share's synthetic root id (the share row id) is
199 // *different* from the admin's real root id (1).
200 let _distractor = share(&admin, "docs", false, None).await;
201 let s = share(&admin, "docs", false, None).await;
202 let token = s["token"].as_str().unwrap();
203 let share_root_id = s["root_id"].as_i64().unwrap();
204 assert_ne!(share_root_id, 1);
205
206 // A signed-in user who opens the share link sees the share scope.
207 let r = admin
208 .get(&format!("/api/files/{share_root_id}?share={token}"))
209 .await;
210 assert_eq!(r.status, StatusCode::OK);
211 // Without the token the same session sees their own roots.
212 let r = admin.get("/api/files/1").await;
213 assert_eq!(r.status, StatusCode::OK);
214 // …but through the share scope their own root id (1) is unreachable.
215 let r = admin.get(&format!("/api/files/1?share={token}")).await;
216 assert_eq!(
217 r.status,
218 StatusCode::FORBIDDEN,
219 "share scope hides own roots"
220 );
221}
222
223#[tokio::test]
224async fn read_only_share_blocks_writes_writable_share_allows() {
225 let env = Env::new().await;
226 let admin = env.admin().await;
227 admin
228 .put_json(
229 "/api/admin/settings",
230 &json!({ "allow_writable_shares": true }),
231 )
232 .await;
233
234 // Writable shares are gated behind the admin setting.
235 let ro = share(&admin, "docs", false, None).await;
236 let rw = share(&admin, "src", true, None).await;
237 assert_eq!(rw["writable"], true);
238
239 let anon = Client::new(env.app.clone());
240
241 // RO share: anonymous write → 403.
242 let ro_tok = ro["token"].as_str().unwrap();
243 let ro_id = ro["root_id"].as_i64().unwrap();
244 let r = anon
245 .raw(
246 axum::http::Method::POST,
247 &format!("/api/files/{ro_id}/x?share={ro_tok}&action=mkdir"),
248 &[],
249 Vec::new(),
250 )
251 .await;
252 assert_eq!(r.status, StatusCode::FORBIDDEN);
253
254 // RW share: anonymous mkdir works (really on disk).
255 let rw_tok = rw["token"].as_str().unwrap();
256 let rw_id = rw["root_id"].as_i64().unwrap();
257 let r = anon
258 .raw(
259 axum::http::Method::POST,
260 &format!("/api/files/{rw_id}/made-by-share?share={rw_tok}&action=mkdir"),
261 &[],
262 Vec::new(),
263 )
264 .await;
265 assert_eq!(r.status, StatusCode::OK);
266 assert!(env.file("src/made-by-share").is_dir());
267
268 // RW share: upload works.
269 let up_path = format!("/api/files/{rw_id}?share={rw_tok}");
270 let r = anon
271 .post_multipart(&up_path, &[("uploaded.txt", b"share-up")], "")
272 .await;
273 assert_eq!(r.status, StatusCode::OK);
274 assert_eq!(
275 std::fs::read(env.file("src/uploaded.txt")).unwrap(),
276 b"share-up"
277 );
278}
279
280#[tokio::test]
281async fn writable_shares_gated_by_setting() {
282 let env = Env::new().await;
283 let admin = env.admin().await;
284
285 // Default: off.
286 let r = admin
287 .post_json(
288 "/api/shares",
289 &json!({ "root_id": 1, "path": "docs", "writable": true, "expires_at": null }),
290 )
291 .await;
292 assert_eq!(r.status, StatusCode::FORBIDDEN);
293
294 // Enable, retry → ok.
295 let r = admin
296 .put_json(
297 "/api/admin/settings",
298 &json!({ "allow_writable_shares": true }),
299 )
300 .await;
301 assert_eq!(r.status, StatusCode::OK);
302 let r = admin
303 .post_json(
304 "/api/shares",
305 &json!({ "root_id": 1, "path": "docs", "writable": true, "expires_at": null }),
306 )
307 .await;
308 assert_eq!(r.status, StatusCode::OK);
309 assert_eq!(r.json()["writable"], true);
310}
311
312#[tokio::test]
313async fn share_expiry() {
314 let env = Env::new().await;
315 let admin = env.admin().await;
316
317 // Past expiry → 410 on resolve and on file access.
318 let s = share(&admin, "docs", false, Some("2000-01-01T00:00:00Z")).await;
319 let token = s["token"].as_str().unwrap();
320 let root_id = s["root_id"].as_i64().unwrap();
321 let anon = Client::new(env.app.clone());
322 assert_eq!(
323 admin.get(&format!("/api/share/{token}")).await.status,
324 StatusCode::GONE
325 );
326 assert_eq!(
327 anon.get(&format!("/api/files/{root_id}?share={token}"))
328 .await
329 .status,
330 StatusCode::GONE
331 );
332
333 // Far-future expiry → live.
334 let s = share(&admin, "docs", false, Some("2999-01-01T00:00:00Z")).await;
335 let token = s["token"].as_str().unwrap();
336 let root_id = s["root_id"].as_i64().unwrap();
337 assert_eq!(
338 admin.get(&format!("/api/share/{token}")).await.status,
339 StatusCode::OK
340 );
341 assert_eq!(
342 anon.get(&format!("/api/files/{root_id}?share={token}"))
343 .await
344 .status,
345 StatusCode::OK
346 );
347}
348
349#[tokio::test]
350async fn share_target_validation() {
351 let env = Env::new().await;
352 let admin = env.admin().await;
353
354 // Missing target → 404.
355 let r = admin
356 .post_json(
357 "/api/shares",
358 &json!({ "root_id": 1, "path": "no-such-folder", "writable": false, "expires_at": null }),
359 )
360 .await;
361 assert_eq!(r.status, StatusCode::NOT_FOUND);
362
363 // Escaping the root → 403.
364 let r = admin
365 .post_json(
366 "/api/shares",
367 &json!({ "root_id": 1, "path": "../../etc", "writable": false, "expires_at": null }),
368 )
369 .await;
370 assert_eq!(r.status, StatusCode::FORBIDDEN);
371
372 // Foreign root id → 403.
373 let r = admin
374 .post_json(
375 "/api/shares",
376 &json!({ "root_id": 999, "path": "docs", "writable": false, "expires_at": null }),
377 )
378 .await;
379 assert_eq!(r.status, StatusCode::FORBIDDEN);
380
381 // Public resolve of a garbage token → 404.
382 let r = admin
383 .get("/api/share/doesnotexist1234567890abcdef1234567890abcdef1234567890abcdef")
384 .await;
385 assert_eq!(r.status, StatusCode::NOT_FOUND);
386
387 // Share management requires auth.
388 let anon = Client::new(env.app.clone());
389 assert_eq!(
390 anon.get("/api/shares").await.status,
391 StatusCode::UNAUTHORIZED
392 );
393}
394
395#[tokio::test]
396async fn share_token_is_not_the_creators_identity() {
397 let env = Env::new().await;
398 let admin = env.admin().await;
399 // The admin owns two shares; the visitor holds the token of one.
400 let secret = share(&admin, "src", false, None).await;
401 let s = share(&admin, "docs", false, None).await;
402 let token = s["token"].as_str().unwrap();
403 let id = s["id"].as_i64().unwrap();
404 let root_id = s["root_id"].as_i64().unwrap();
405
406 let anon = Client::new(env.app.clone());
407
408 // Listing shares with a share token must not leak the creator's shares
409 // (which would include every token they own).
410 let r = anon.get(&format!("/api/shares?share={token}")).await;
411 assert!(
412 r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN,
413 "list shares with share token: {} {}",
414 r.status,
415 r.text()
416 );
417 assert!(!r.text().contains(secret["token"].as_str().unwrap()));
418
419 // Nor create shares as the creator.
420 let r = anon
421 .post_json(
422 &format!("/api/shares?share={token}"),
423 &json!({ "root_id": root_id, "path": ".", "writable": false, "expires_at": null }),
424 )
425 .await;
426 assert!(r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN);
427
428 // Nor delete the creator's shares.
429 let r = anon
430 .delete(&format!("/api/shares/{id}?share={token}"))
431 .await;
432 assert!(r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN);
433 assert_eq!(
434 admin
435 .get("/api/shares")
436 .await
437 .json()
438 .as_array()
439 .unwrap()
440 .len(),
441 2
442 );
443
444 // Nor reach the admin API. The header form is blocked as well.
445 for path in ["/api/admin/users", "/api/admin/settings"] {
446 let r = anon.get(&format!("{path}?share={token}")).await;
447 assert!(
448 r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN,
449 "{path}: {}",
450 r.status
451 );
452 let r = anon
453 .raw(
454 axum::http::Method::GET,
455 path,
456 &[("x-share-token", token)],
457 Vec::new(),
458 )
459 .await;
460 assert!(r.status == StatusCode::UNAUTHORIZED || r.status == StatusCode::FORBIDDEN);
461 }
462
463 // A real session still manages its own shares, and the share still browses.
464 assert_eq!(admin.get("/api/shares").await.status, StatusCode::OK);
465 assert_eq!(
466 anon.get(&format!("/api/files/{root_id}?share={token}"))
467 .await
468 .status,
469 StatusCode::OK
470 );
471}
472
473#[tokio::test]
474async fn read_only_root_cannot_be_shared_writably() {
475 let env = Env::new().await;
476 let admin = env.admin().await;
477 admin
478 .put_json(
479 "/api/admin/settings",
480 &json!({ "allow_writable_shares": true }),
481 )
482 .await;
483 // Bob sees "docs" read-only and "src" read-write.
484 create_user(&admin, "bob", "bobpass1", &[("docs", "ro"), ("src", "rw")]).await;
485 let bob = login(&env, "bob", "bobpass1").await;
486
487 let roots = bob.get("/api/auth/me").await.json();
488 let roots = roots["roots"].as_array().unwrap().clone();
489 let root_id = |mode: &str| -> i64 {
490 roots.iter().find(|r| r["mode"] == mode).unwrap()["id"]
491 .as_i64()
492 .unwrap()
493 };
494
495 // Writable share of the read-only root → 403.
496 let r = bob
497 .post_json(
498 "/api/shares",
499 &json!({ "root_id": root_id("ro"), "path": ".", "writable": true, "expires_at": null }),
500 )
501 .await;
502 assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
503
504 // A read-only share of the same root is fine.
505 let r = bob
506 .post_json(
507 "/api/shares",
508 &json!({ "root_id": root_id("ro"), "path": ".", "writable": false, "expires_at": null }),
509 )
510 .await;
511 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
512 assert_eq!(r.json()["writable"], false);
513
514 // And the read-write root can still be shared writably.
515 let r = bob
516 .post_json(
517 "/api/shares",
518 &json!({ "root_id": root_id("rw"), "path": ".", "writable": true, "expires_at": null }),
519 )
520 .await;
521 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
522 assert_eq!(r.json()["writable"], true);
523}
524
525/// A share names a path, and a delete, rename or move frees that path. The
526/// share must die with it, otherwise a *new* item that later takes the freed
527/// name inherits the old link's audience.
528#[tokio::test]
529async fn mutating_a_target_revokes_its_shares() {
530 let env = Env::new().await;
531 let admin = env.admin().await;
532 let files = |rel: &str| format!("/api/files/1/{rel}");
533 let alive = async |token: &str| {
534 admin.get(&format!("/api/share/{token}")).await.status == StatusCode::OK
535 };
536
537 // 1. Delete revokes the share on the deleted item *and* on its children.
538 let parent = share(&admin, "docs", false, None).await;
539 let child = share(&admin, "docs/a.txt", false, None).await;
540 let bystander = share(&admin, "src/main.rs", false, None).await;
541 let (parent, child, bystander) = (
542 parent["token"].as_str().unwrap().to_string(),
543 child["token"].as_str().unwrap().to_string(),
544 bystander["token"].as_str().unwrap().to_string(),
545 );
546 assert_eq!(admin.delete(&files("docs")).await.status, StatusCode::OK);
547 assert!(!alive(&parent).await, "share on the deleted folder");
548 assert!(!alive(&child).await, "share on a file inside it");
549 assert!(alive(&bystander).await, "an unrelated share must survive");
550
551 // 2. Rename frees the old name, so the share on it goes too. The proof
552 // that this matters: a new file takes the freed name right after.
553 let renamed = share(&admin, "notes.md", false, None).await;
554 let renamed = renamed["token"].as_str().unwrap().to_string();
555 let r = admin
556 .post_json(
557 &files("notes.md"),
558 &json!({"op": "rename", "new_name": "notes-old.md"}),
559 )
560 .await;
561 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
562 assert!(!alive(&renamed).await, "share on the vacated name");
563
564 std::fs::write(env.file("notes.md"), "a different file entirely").unwrap();
565 assert!(
566 !alive(&renamed).await,
567 "the old link must not pick up the new file at that path"
568 );
569
570 // 3. Move frees the source path the same way.
571 let moved = share(&admin, "config.json", false, None).await;
572 let moved = moved["token"].as_str().unwrap().to_string();
573 let r = admin
574 .post_json(
575 &files("config.json"),
576 &json!({"op": "move", "dst_root_id": 1, "dst": "src"}),
577 )
578 .await;
579 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
580 assert!(!alive(&moved).await, "share on the vacated source path");
581
582 // 4. A copy frees nothing, so it revokes nothing.
583 let copied = share(&admin, "editme.txt", false, None).await;
584 let copied = copied["token"].as_str().unwrap().to_string();
585 let r = admin
586 .post_json(
587 &files("editme.txt"),
588 &json!({"op": "copy", "dst_root_id": 1, "dst": "src"}),
589 )
590 .await;
591 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
592 assert!(alive(&copied).await, "a copy must leave the share alone");
593}
594
595/// A password must gate the files, not only the share page. The file API
596/// with `?share=<token>` never goes through the resolve endpoint.
597#[tokio::test]
598async fn a_password_locks_the_share_and_its_files() {
599 let env = Env::new().await;
600 let admin = env.admin().await;
601
602 let r = admin
603 .post_json(
604 "/api/shares",
605 &json!({"root_id": 1, "path": "docs", "password": "hunter22"}),
606 )
607 .await;
608 assert_eq!(r.status, StatusCode::OK, "create: {}", r.text());
609 let s = r.json();
610 assert_eq!(s["has_password"], true);
611 let token = s["token"].as_str().unwrap().to_string();
612 let root_id = s["root_id"].as_i64().unwrap();
613
614 let anon = Client::new(env.app.clone());
615
616 // The share page is locked, with its own status so the client can tell
617 // it apart from a dead link.
618 let r = anon.get(&format!("/api/share/{token}")).await;
619 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
620 assert_eq!(r.json()["code"], "err_share_locked");
621
622 // ... and so is every file call carrying the token.
623 let r = anon
624 .get(&format!("/api/files/{root_id}?share={token}"))
625 .await;
626 assert_eq!(r.status, StatusCode::UNAUTHORIZED, "listing was not gated");
627 let r = anon
628 .get(&format!(
629 "/api/files/{root_id}/a.txt?share={token}&action=download"
630 ))
631 .await;
632 assert_eq!(r.status, StatusCode::UNAUTHORIZED, "download was not gated");
633
634 // Wrong password: no cookie, still locked.
635 let r = anon
636 .post_json(
637 &format!("/api/share/{token}/unlock"),
638 &json!({"password": "wrong-one"}),
639 )
640 .await;
641 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
642 assert!(r.header("set-cookie").is_none());
643
644 // Right password: the unlock cookie comes back.
645 let r = anon
646 .post_json(
647 &format!("/api/share/{token}/unlock"),
648 &json!({"password": "hunter22"}),
649 )
650 .await;
651 assert_eq!(r.status, StatusCode::OK, "unlock: {}", r.text());
652 let cookie = r.header("set-cookie").expect("unlock sets a cookie");
653 let share_id = s["id"].as_i64().unwrap();
654 assert!(cookie.starts_with(&format!("fbng_share_{share_id}=")));
655 assert!(cookie.contains("HttpOnly"));
656 let value = cookie
657 .split(';')
658 .next()
659 .unwrap()
660 .split_once('=')
661 .unwrap()
662 .1
663 .to_string();
664
665 // With the cookie, both the page and the files open.
666 let hdr: &[(&str, &str)] = &[("cookie", &format!("fbng_share_{share_id}={value}"))];
667 let r = anon
668 .raw(
669 axum::http::Method::GET,
670 &format!("/api/share/{token}"),
671 hdr,
672 Vec::new(),
673 )
674 .await;
675 assert_eq!(r.status, StatusCode::OK, "unlocked resolve: {}", r.text());
676 let r = anon
677 .raw(
678 axum::http::Method::GET,
679 &format!("/api/files/{root_id}?share={token}"),
680 hdr,
681 Vec::new(),
682 )
683 .await;
684 assert_eq!(r.status, StatusCode::OK, "unlocked listing: {}", r.text());
685
686 // An unlock for one share must not open another.
687 let other = admin
688 .post_json(
689 "/api/shares",
690 &json!({"root_id": 1, "path": "docs/a.txt", "password": "hunter22"}),
691 )
692 .await
693 .json();
694 let other_token = other["token"].as_str().unwrap();
695 let other_root = other["root_id"].as_i64().unwrap();
696 let r = anon
697 .raw(
698 axum::http::Method::GET,
699 &format!("/api/files/{other_root}?share={other_token}"),
700 hdr,
701 Vec::new(),
702 )
703 .await;
704 assert_eq!(r.status, StatusCode::UNAUTHORIZED, "cookie crossed shares");
705}
706
707/// A short password is refused, the same as for an account.
708#[tokio::test]
709async fn a_share_password_must_be_long_enough() {
710 let env = Env::new().await;
711 let admin = env.admin().await;
712 let r = admin
713 .post_json(
714 "/api/shares",
715 &json!({"root_id": 1, "path": "docs", "password": "short"}),
716 )
717 .await;
718 assert_eq!(r.status, StatusCode::BAD_REQUEST);
719 assert_eq!(r.json()["code"], "err_password_short");
720 // Nothing was created.
721 assert!(
722 admin
723 .get("/api/shares")
724 .await
725 .json()
726 .as_array()
727 .unwrap()
728 .is_empty()
729 );
730}
731
732/// Unlocking a share that has no password is a client bug, not a way to mint
733/// a cookie.
734#[tokio::test]
735async fn unlocking_an_open_share_is_rejected() {
736 let env = Env::new().await;
737 let admin = env.admin().await;
738 let s = share(&admin, "docs", false, None).await;
739 let token = s["token"].as_str().unwrap();
740 let anon = Client::new(env.app.clone());
741 let r = anon
742 .post_json(
743 &format!("/api/share/{token}/unlock"),
744 &json!({"password": "whatever"}),
745 )
746 .await;
747 assert_eq!(r.status, StatusCode::BAD_REQUEST);
748 assert!(r.header("set-cookie").is_none());
749}
750
751/// A password-protected *file* share must open its viewer after the correct
752/// password. The unlock response is the only one the client sees in that
753/// flow, so it has to carry the file's kind just as `resolve` does.
754#[tokio::test]
755async fn unlocking_a_file_share_returns_its_kind() {
756 let env = Env::new().await;
757 let admin = env.admin().await;
758 let s = admin
759 .post_json(
760 "/api/shares",
761 &json!({"root_id": 1, "path": "docs/a.txt", "password": "hunter22"}),
762 )
763 .await
764 .json();
765 assert_eq!(s["is_file"], true);
766 let token = s["token"].as_str().unwrap();
767
768 let anon = Client::new(env.app.clone());
769 let r = anon
770 .post_json(
771 &format!("/api/share/{token}/unlock"),
772 &json!({"password": "hunter22"}),
773 )
774 .await;
775 assert_eq!(r.status, StatusCode::OK, "unlock: {}", r.text());
776 assert_eq!(
777 r.json()["kind"],
778 "text",
779 "unlock dropped the kind, so the share page would render nothing"
780 );
781}
782