api_files.rs
⎇
Raw
1//! File API: listing, download/preview/content, editor save, mutations,
2//! upload, access control and path-safety.
3
4mod common;
5
6use axum::http::StatusCode;
7use common::*;
8use serde_json::json;
9
10/// Root id for the whole-root (".") user root is 1 (first row inserted).
11const ROOT: i64 = 1;
12
13fn root_path(rel: &str) -> String {
14 // No trailing slash for the bare root: axum's routes are
15 // `/api/files/{root_id}` and `/api/files/{root_id}/{*path}`.
16 if rel.is_empty() {
17 format!("/api/files/{ROOT}")
18 } else {
19 format!("/api/files/{ROOT}/{rel}")
20 }
21}
22
23#[tokio::test]
24async fn list_root_sorted_folders_first() {
25 let env = Env::new().await;
26 let admin = env.admin().await;
27 let r = admin.get(&root_path("")).await;
28 assert_eq!(r.status, StatusCode::OK);
29 let j = r.json();
30 let entries = j["entries"].as_array().unwrap();
31 let names: Vec<&str> = entries
32 .iter()
33 .map(|e| e["name"].as_str().unwrap())
34 .collect();
35 assert_eq!(
36 names,
37 vec![
38 "docs",
39 "src",
40 "blob.bin",
41 "config.json",
42 "editme.txt",
43 "notes.md"
44 ]
45 );
46 // Entry fields.
47 let docs = &entries[0];
48 assert_eq!(docs["is_dir"], true);
49 let editme = entries.iter().find(|e| e["name"] == "editme.txt").unwrap();
50 assert_eq!(editme["is_dir"], false);
51 assert_eq!(editme["size"], 2);
52 assert!(editme["mtime"].as_str().unwrap().ends_with('Z'));
53}
54
55#[tokio::test]
56async fn list_subdir_and_errors() {
57 let env = Env::new().await;
58 let admin = env.admin().await;
59
60 let r = admin.get(&root_path("docs")).await;
61 let j = r.json();
62 let names: Vec<&str> = j
63 .get("entries")
64 .unwrap()
65 .as_array()
66 .unwrap()
67 .iter()
68 .map(|e| e["name"].as_str().unwrap())
69 .collect();
70 assert_eq!(names, vec!["inner", "a.txt"]);
71
72 // Missing path → 404.
73 assert_eq!(
74 admin.get(&root_path("nope")).await.status,
75 StatusCode::NOT_FOUND
76 );
77 // Listing a file → 400.
78 assert_eq!(
79 admin.get(&root_path("editme.txt")).await.status,
80 StatusCode::BAD_REQUEST
81 );
82 // Unknown root id → 403.
83 assert_eq!(
84 admin.get("/api/files/999").await.status,
85 StatusCode::FORBIDDEN
86 );
87 // No session → 401.
88 let anon = Client::new(env.app.clone());
89 assert_eq!(
90 anon.get(&root_path("")).await.status,
91 StatusCode::UNAUTHORIZED
92 );
93}
94
95#[tokio::test]
96async fn path_traversal_is_blocked() {
97 let env = Env::new().await;
98 let admin = env.admin().await;
99
100 // Encoded `..` segments reach the handler and are rejected.
101 let r = admin.get("/api/files/1/%2e%2e%2f%2e%2e%2fetc").await;
102 assert!(
103 r.status == StatusCode::FORBIDDEN || r.status == StatusCode::NOT_FOUND,
104 "traversal returned {:?}",
105 r.status
106 );
107 // Literal `..` segments: must never succeed.
108 let r = admin.get("/api/files/1/../../etc").await;
109 assert_ne!(
110 r.status,
111 StatusCode::OK,
112 "literal traversal must not be served"
113 );
114 // Traversal inside a deeper path.
115 let r = admin.get("/api/files/1/docs/..%2f..%2fsrc").await;
116 assert!(
117 r.status == StatusCode::FORBIDDEN || r.status == StatusCode::NOT_FOUND,
118 "deep traversal returned {:?}",
119 r.status
120 );
121}
122
123#[tokio::test]
124async fn download_single_file() {
125 let env = Env::new().await;
126 let admin = env.admin().await;
127 let r = admin
128 .get(&format!("{}?action=download", root_path("editme.txt")))
129 .await;
130 assert_eq!(r.status, StatusCode::OK);
131 assert_eq!(
132 r.header("content-disposition").as_deref(),
133 Some("attachment; filename=\"editme.txt\"")
134 );
135 assert_eq!(r.header("content-type").as_deref(), Some("text/plain"));
136 assert_eq!(r.body, b"v1");
137 // Binary content survives.
138 let r = admin
139 .get(&format!("{}?action=download", root_path("blob.bin")))
140 .await;
141 assert_eq!(r.body, (0..64u8).collect::<Vec<_>>());
142}
143
144#[tokio::test]
145async fn download_folder_as_all_archive_formats() {
146 let env = Env::new().await;
147 let admin = env.admin().await;
148 let path = format!("{}?action=download", root_path("docs"));
149
150 let r = admin.get(&format!("{path}&format=zip")).await;
151 assert_eq!(r.status, StatusCode::OK);
152 assert_eq!(r.header("content-type").as_deref(), Some("application/zip"));
153 assert_eq!(
154 r.header("content-disposition").as_deref(),
155 Some("attachment; filename=\"docs.zip\"")
156 );
157 let map = zip_map(&r.body);
158 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
159 assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
160
161 let r = admin.get(&format!("{path}&format=tar")).await;
162 assert_eq!(
163 r.header("content-type").as_deref(),
164 Some("application/x-tar")
165 );
166 assert_eq!(
167 r.header("content-disposition").as_deref(),
168 Some("attachment; filename=\"docs.tar\"")
169 );
170 let map = tar_map(&r.body, Compress::None);
171 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
172 assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
173
174 let r = admin.get(&format!("{path}&format=tar.gz")).await;
175 assert_eq!(
176 r.header("content-type").as_deref(),
177 Some("application/gzip")
178 );
179 assert_eq!(
180 r.header("content-disposition").as_deref(),
181 Some("attachment; filename=\"docs.tar.gz\"")
182 );
183 let map = tar_map(&r.body, Compress::Gz);
184 assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
185
186 let r = admin.get(&format!("{path}&format=tar.zst")).await;
187 assert_eq!(
188 r.header("content-type").as_deref(),
189 Some("application/zstd")
190 );
191 assert_eq!(
192 r.header("content-disposition").as_deref(),
193 Some("attachment; filename=\"docs.tar.zst\"")
194 );
195 let map = tar_map(&r.body, Compress::Zst);
196 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
197}
198
199#[tokio::test]
200async fn download_folder_requires_valid_format() {
201 let env = Env::new().await;
202 let admin = env.admin().await;
203 let path = format!("{}?action=download", root_path("docs"));
204 // No format → 400.
205 assert_eq!(admin.get(&path).await.status, StatusCode::BAD_REQUEST);
206 // Unknown format → 400.
207 assert_eq!(
208 admin.get(&format!("{path}&format=rar")).await.status,
209 StatusCode::BAD_REQUEST
210 );
211 // Downloading a file with a format is fine (format ignored).
212 let r = admin
213 .get(&format!(
214 "{}?action=download&format=zip",
215 root_path("editme.txt")
216 ))
217 .await;
218 assert_eq!(r.status, StatusCode::OK);
219 assert_eq!(r.body, b"v1");
220}
221
222#[tokio::test]
223async fn preview_serves_inline_and_rejects_dirs() {
224 let env = Env::new().await;
225 let admin = env.admin().await;
226 let r = admin
227 .get(&format!("{}?action=preview", root_path("config.json")))
228 .await;
229 assert_eq!(r.status, StatusCode::OK);
230 assert!(r
231 .header("content-disposition")
232 .unwrap()
233 .starts_with("inline;"));
234 assert_eq!(r.body, b"{\"k\": 1}");
235 assert_eq!(
236 admin
237 .get(&format!("{}?action=preview", root_path("docs")))
238 .await
239 .status,
240 StatusCode::BAD_REQUEST
241 );
242}
243
244#[tokio::test]
245async fn content_action_serves_raw_bytes_with_mtime() {
246 let env = Env::new().await;
247 let admin = env.admin().await;
248 let r = admin
249 .get(&format!("{}?action=content", root_path("notes.md")))
250 .await;
251 assert_eq!(r.status, StatusCode::OK);
252 assert_eq!(
253 r.header("content-type").as_deref(),
254 Some("text/plain; charset=utf-8")
255 );
256 let mtime = r.header("x-file-mtime").unwrap();
257 assert!(mtime.parse::<i64>().is_ok());
258 assert_eq!(r.body, b"# notes");
259 assert_eq!(
260 admin
261 .get(&format!("{}?action=content", root_path("docs")))
262 .await
263 .status,
264 StatusCode::BAD_REQUEST
265 );
266}
267
268#[tokio::test]
269async fn content_is_capped_at_two_mibibytes() {
270 let env = Env::new().await;
271 let admin = env.admin().await;
272 let big = vec![b'x'; 2 * 1024 * 1024 + 1];
273 std::fs::write(env.file("big.bin"), &big).unwrap();
274 let r = admin
275 .get(&format!("{}?action=content", root_path("big.bin")))
276 .await;
277 assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE);
278 // The file itself still downloads fine.
279 let r = admin
280 .get(&format!("{}?action=download", root_path("big.bin")))
281 .await;
282 assert_eq!(r.status, StatusCode::OK);
283 assert_eq!(r.body.len(), big.len());
284}
285
286#[tokio::test]
287async fn editor_save_round_trip_and_conflict() {
288 let env = Env::new().await;
289 let admin = env.admin().await;
290 let path = format!("{}?action=content", root_path("editme.txt"));
291
292 // Read current mtime via the content endpoint.
293 let r = admin.get(&path).await;
294 assert_eq!(r.status, StatusCode::OK);
295 let mtime: i64 = r.header("x-file-mtime").unwrap().parse().unwrap();
296
297 // Save with a matching expected mtime.
298 let r = admin.put_content(&path, b"v2", Some(mtime)).await;
299 assert_eq!(r.status, StatusCode::OK);
300 let new_mtime = r.json()["mtime"].as_i64().unwrap();
301 assert!(new_mtime >= mtime);
302 assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v2");
303
304 // A stale/wrong expected mtime conflicts (409). Use a value far from the
305 // current mtime so this is deterministic regardless of the filesystem's
306 // timestamp granularity (the mtime may not have advanced after the save).
307 let r = admin.put_content(&path, b"v3", Some(mtime + 999_999)).await;
308 assert_eq!(r.status, StatusCode::CONFLICT);
309 // A conflict must not modify the file.
310 assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v2");
311
312 // No expected mtime → force save.
313 let r = admin.put_content(&path, b"v4", None).await;
314 assert_eq!(r.status, StatusCode::OK);
315 assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v4");
316
317 // Saving a missing file → 404; a directory → 400.
318 // (PUT without action=content → 400.)
319 let r = admin
320 .raw(
321 axum::http::Method::PUT,
322 &root_path("editme.txt"),
323 &[("content-type", "text/plain")],
324 b"x".to_vec(),
325 )
326 .await;
327 assert_eq!(r.status, StatusCode::BAD_REQUEST);
328
329 let r = admin
330 .put_content(
331 &format!("{}?action=content", root_path("ghost.txt")),
332 b"x",
333 None,
334 )
335 .await;
336 assert_eq!(r.status, StatusCode::NOT_FOUND);
337 let r = admin
338 .put_content(&format!("{}?action=content", root_path("docs")), b"x", None)
339 .await;
340 assert_eq!(r.status, StatusCode::BAD_REQUEST);
341
342 // Oversized body → 413.
343 let r = admin
344 .put_content(&path, &vec![b'a'; 2 * 1024 * 1024 + 1], None)
345 .await;
346 assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE);
347}
348
349#[tokio::test]
350async fn mkdir_and_rename() {
351 let env = Env::new().await;
352 let admin = env.admin().await;
353
354 // mkdir (no content-type → mkdir dispatch).
355 let r = admin
356 .raw(
357 axum::http::Method::POST,
358 &root_path("newdir"),
359 &[],
360 Vec::new(),
361 )
362 .await;
363 assert_eq!(r.status, StatusCode::OK);
364 assert!(env.file("newdir").is_dir());
365 // Duplicate → 409.
366 let r = admin
367 .raw(
368 axum::http::Method::POST,
369 &root_path("newdir"),
370 &[],
371 Vec::new(),
372 )
373 .await;
374 assert_eq!(r.status, StatusCode::CONFLICT);
375 // Empty name → 400 (bare root POST with JSON op is rejected too).
376 let r = admin
377 .raw(axum::http::Method::POST, &root_path(""), &[], Vec::new())
378 .await;
379 assert_eq!(r.status, StatusCode::BAD_REQUEST);
380
381 // Rename.
382 let r = admin
383 .post_json(
384 &root_path("editme.txt"),
385 &json!({ "op": "rename", "new_name": "renamed.txt" }),
386 )
387 .await;
388 assert_eq!(r.status, StatusCode::OK);
389 assert!(env.file("renamed.txt").exists());
390 // Conflict.
391 let r = admin
392 .post_json(
393 &root_path("renamed.txt"),
394 &json!({ "op": "rename", "new_name": "config.json" }),
395 )
396 .await;
397 assert_eq!(r.status, StatusCode::CONFLICT);
398 // With overwrite.
399 let r = admin
400 .post_json(
401 &root_path("renamed.txt"),
402 &json!({ "op": "rename", "new_name": "config.json", "overwrite": true }),
403 )
404 .await;
405 assert_eq!(r.status, StatusCode::OK);
406 assert_eq!(std::fs::read(env.file("config.json")).unwrap(), b"v1");
407 // Invalid name.
408 let r = admin
409 .post_json(
410 &root_path("notes.md"),
411 &json!({ "op": "rename", "new_name": "a/b" }),
412 )
413 .await;
414 assert_eq!(r.status, StatusCode::BAD_REQUEST);
415 // Missing source.
416 let r = admin
417 .post_json(
418 &root_path("ghost"),
419 &json!({ "op": "rename", "new_name": "x" }),
420 )
421 .await;
422 assert_eq!(r.status, StatusCode::NOT_FOUND);
423 // Unknown op.
424 let r = admin
425 .post_json(&root_path("notes.md"), &json!({ "op": "explode" }))
426 .await;
427 assert_eq!(r.status, StatusCode::BAD_REQUEST);
428}
429
430#[tokio::test]
431async fn move_and_copy_across_dirs() {
432 let env = Env::new().await;
433 let admin = env.admin().await;
434
435 // Move notes.md into docs/.
436 let r = admin
437 .post_json(
438 &root_path("notes.md"),
439 &json!({ "op": "move", "dst_root_id": ROOT, "dst": "docs" }),
440 )
441 .await;
442 assert_eq!(r.status, StatusCode::OK);
443 assert!(!env.file("notes.md").exists());
444 assert_eq!(
445 std::fs::read(env.file("docs/notes.md")).unwrap(),
446 b"# notes"
447 );
448
449 // Copy docs/inner back out — as a folder.
450 let r = admin
451 .post_json(
452 &root_path("docs/inner"),
453 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }),
454 )
455 .await;
456 assert_eq!(r.status, StatusCode::OK);
457 assert_eq!(
458 std::fs::read(env.file("src/inner/hello.txt")).unwrap(),
459 b"hello world"
460 );
461 assert!(env.file("docs/inner/hello.txt").exists());
462
463 // Conflict without overwrite, ok with: copy into a folder that already
464 // holds a file with the same name.
465 let r = admin
466 .post_json(
467 &root_path("docs/a.txt"),
468 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }),
469 )
470 .await;
471 assert_eq!(r.status, StatusCode::OK);
472 assert_eq!(std::fs::read(env.file("src/a.txt")).unwrap(), b"file a");
473 std::fs::write(env.file("docs/a.txt"), "file a2").unwrap();
474 let r = admin
475 .post_json(
476 &root_path("docs/a.txt"),
477 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }),
478 )
479 .await;
480 assert_eq!(r.status, StatusCode::CONFLICT);
481 let r = admin
482 .post_json(
483 &root_path("docs/a.txt"),
484 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src", "overwrite": true }),
485 )
486 .await;
487 assert_eq!(r.status, StatusCode::OK);
488 assert_eq!(std::fs::read(env.file("src/a.txt")).unwrap(), b"file a2");
489
490 // Copying an item into its own folder (same path) is a no-op success.
491 let r = admin
492 .post_json(
493 &root_path("docs/a.txt"),
494 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "docs" }),
495 )
496 .await;
497 assert_eq!(r.status, StatusCode::OK);
498
499 // Moving a folder into itself → 400.
500 let r = admin
501 .post_json(
502 &root_path("docs"),
503 &json!({ "op": "move", "dst_root_id": ROOT, "dst": "docs" }),
504 )
505 .await;
506 assert_eq!(r.status, StatusCode::BAD_REQUEST);
507
508 // Missing dst_root_id / dst dir.
509 let r = admin
510 .post_json(&root_path("docs/a.txt"), &json!({ "op": "move" }))
511 .await;
512 assert_eq!(r.status, StatusCode::BAD_REQUEST);
513 let r = admin
514 .post_json(
515 &root_path("docs/a.txt"),
516 &json!({ "op": "move", "dst_root_id": ROOT, "dst": "no-such-dir" }),
517 )
518 .await;
519 assert_eq!(r.status, StatusCode::NOT_FOUND);
520}
521
522#[tokio::test]
523async fn delete_file_and_folder() {
524 let env = Env::new().await;
525 let admin = env.admin().await;
526
527 let r = admin.delete(&root_path("editme.txt")).await;
528 assert_eq!(r.status, StatusCode::OK);
529 assert_eq!(r.json()["is_dir"], false);
530 assert!(!env.file("editme.txt").exists());
531
532 let r = admin.delete(&root_path("docs")).await;
533 assert_eq!(r.json()["is_dir"], true);
534 assert!(!env.file("docs").exists());
535
536 // Missing → 404. A DELETE on the bare root matches no route's method →
537 // 405 (the path only has GET/POST routes).
538 assert_eq!(
539 admin.delete(&root_path("ghost")).await.status,
540 StatusCode::NOT_FOUND
541 );
542 assert_eq!(
543 admin.delete("/api/files/1").await.status,
544 StatusCode::METHOD_NOT_ALLOWED
545 );
546 // DELETE with a trailing-slash root matches no route at all → 404 via
547 // the SPA fallback's API guard.
548 let r = admin.delete("/api/files/1/").await;
549 assert_eq!(r.status, StatusCode::NOT_FOUND);
550 assert_eq!(r.text(), "unknown endpoint");
551}
552
553#[tokio::test]
554async fn upload_creates_files_and_folders() {
555 let env = Env::new().await;
556 let admin = env.admin().await;
557
558 // Single file into the root, nested part name creates the folder.
559 let r = admin
560 .post_multipart(
561 &root_path(""),
562 &[("docs/uploaded.txt", b"up1"), ("new/nested.txt", b"up2")],
563 "",
564 )
565 .await;
566 assert_eq!(r.status, StatusCode::OK);
567 assert_eq!(r.json()["uploaded"], 2);
568 assert_eq!(
569 std::fs::read(env.file("docs/uploaded.txt")).unwrap(),
570 b"up1"
571 );
572 assert_eq!(std::fs::read(env.file("new/nested.txt")).unwrap(), b"up2");
573
574 // Conflict: existing file, no overwrite → 409 with the skipped list.
575 let r = admin
576 .post_multipart(&root_path(""), &[("docs/uploaded.txt", b"again")], "")
577 .await;
578 assert_eq!(r.status, StatusCode::CONFLICT);
579 assert_eq!(r.json()["skipped"], json!(["docs/uploaded.txt"]));
580 assert_eq!(
581 std::fs::read(env.file("docs/uploaded.txt")).unwrap(),
582 b"up1"
583 );
584
585 // Mixed: one conflict + one new file → 409, the new one is uploaded.
586 let r = admin
587 .post_multipart(
588 &root_path(""),
589 &[("docs/uploaded.txt", b"again"), ("fresh.txt", b"new")],
590 "",
591 )
592 .await;
593 assert_eq!(r.status, StatusCode::CONFLICT);
594 assert_eq!(r.json()["skipped"], json!(["docs/uploaded.txt"]));
595 assert_eq!(r.json()["uploaded"], 1);
596 assert_eq!(std::fs::read(env.file("fresh.txt")).unwrap(), b"new");
597
598 // overwrite=true replaces.
599 let r = admin
600 .post_multipart(
601 &root_path(""),
602 &[("docs/uploaded.txt", b"v3")],
603 "overwrite=true",
604 )
605 .await;
606 assert_eq!(r.status, StatusCode::OK);
607 assert_eq!(std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"v3");
608
609 // A part name that is an existing directory → 409.
610 let r = admin
611 .post_multipart(&root_path(""), &[("new", b"dir?")], "")
612 .await;
613 assert_eq!(r.status, StatusCode::CONFLICT);
614
615 // Path traversal in a part name → 400.
616 let r = admin
617 .post_multipart(&root_path(""), &[("../evil.txt", b"x")], "")
618 .await;
619 assert!(matches!(
620 r.status,
621 StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN
622 ));
623 assert!(!env.file("../evil.txt").exists());
624 assert!(!env.root.path().parent().unwrap().join("evil.txt").exists());
625
626 // No parts at all → 400.
627 let (ct, body) = multipart_body(&[], "b");
628 let r = admin
629 .raw(
630 axum::http::Method::POST,
631 &root_path(""),
632 &[("content-type", &ct)],
633 body,
634 )
635 .await;
636 assert_eq!(r.status, StatusCode::BAD_REQUEST);
637}
638
639#[tokio::test]
640async fn read_only_root_blocks_writes_but_allows_reads() {
641 let env = Env::new().await;
642 let admin = env.admin().await;
643 create_user(&admin, "carol", "carolpass1", &[("docs", "ro")]).await;
644 let carol = login(&env, "carol", "carolpass1").await;
645 let carol_root_id = carol.get("/api/auth/me").await.json()["roots"][0]["id"]
646 .as_i64()
647 .unwrap();
648
649 // Reads work.
650 let r = carol.get(&format!("/api/files/{carol_root_id}")).await;
651 assert_eq!(r.status, StatusCode::OK);
652 assert!(!r.json()["entries"].as_array().unwrap().is_empty());
653 let r = carol
654 .get(&format!("/api/files/{carol_root_id}/a.txt?action=download"))
655 .await;
656 assert_eq!(r.body, b"file a");
657
658 // Writes are blocked.
659 let base = format!("/api/files/{carol_root_id}/x");
660 assert_eq!(
661 carol
662 .raw(axum::http::Method::POST, &base, &[], Vec::new())
663 .await
664 .status,
665 StatusCode::FORBIDDEN
666 );
667 assert_eq!(
668 carol
669 .delete(&format!("/api/files/{carol_root_id}/a.txt"))
670 .await
671 .status,
672 StatusCode::FORBIDDEN
673 );
674 assert_eq!(
675 carol
676 .post_json(
677 &format!("/api/files/{carol_root_id}/a.txt"),
678 &json!({ "op": "rename", "new_name": "b.txt" })
679 )
680 .await
681 .status,
682 StatusCode::FORBIDDEN
683 );
684}
685
686#[tokio::test]
687async fn user_cannot_touch_foreign_root() {
688 let env = Env::new().await;
689 let admin = env.admin().await;
690 create_user(&admin, "dave", "davepass12", &[("src", "rw")]).await;
691 let dave = login(&env, "dave", "davepass12").await;
692 let dave_root_id = dave.get("/api/auth/me").await.json()["roots"][0]["id"]
693 .as_i64()
694 .unwrap();
695
696 // His own root works.
697 assert_eq!(
698 dave.get(&format!("/api/files/{dave_root_id}")).await.status,
699 StatusCode::OK
700 );
701 // The admin's root id (1) is not his → 403.
702 assert_eq!(dave.get("/api/files/1").await.status, StatusCode::FORBIDDEN);
703 // Writing into a root he doesn't have → 403.
704 assert_eq!(
705 dave.raw(
706 axum::http::Method::POST,
707 "/api/files/1/evil",
708 &[],
709 Vec::new()
710 )
711 .await
712 .status,
713 StatusCode::FORBIDDEN
714 );
715}
716