1use std::sync::Arc;
2
3use axum::Json;
4use axum::extract::{Path, State};
5use axum::http::StatusCode;
6use axum::response::Redirect;
7use kellnr_appstate::{AppState, DbState, DocsStorageState, SettingsState};
8use kellnr_auth::crate_access::can_read_crate;
9use kellnr_auth::maybe_user::MaybeUser;
10use kellnr_auth::token::Token;
11use kellnr_common::normalized_name::NormalizedName;
12use kellnr_common::original_name::OriginalName;
13use kellnr_common::version::Version;
14use kellnr_db::DbProvider;
15use kellnr_error::api_error::ApiResult;
16use kellnr_registry::kellnr_api::{check_can_modify, check_ownership};
17use tracing::error;
18
19use crate::doc_archive::DocArchive;
20use crate::doc_queue_response::DocQueueResponse;
21use crate::docs_error::DocsError;
22use crate::upload::upload_dir_and_prune;
23use crate::upload_response::DocUploadResponse;
24use crate::{compute_doc_url, get_latest_version_with_doc};
25
26#[utoipa::path(
31 get,
32 path = "/builds",
33 tag = "docs",
34 responses(
35 (status = 200, description = "Documentation build queue", body = DocQueueResponse)
36 ),
37 security(("session_cookie" = []))
38)]
39pub async fn docs_in_queue(
40 user: Result<MaybeUser, StatusCode>,
41 State(db): DbState,
42) -> ApiResult<Json<DocQueueResponse>> {
43 let user = user.ok();
44 let mut visible = Vec::new();
45 for entry in db.get_doc_queue().await? {
46 if can_read_docs(&entry.normalized_name, user.as_ref(), &db).await {
47 visible.push(entry);
48 }
49 }
50 Ok(Json(DocQueueResponse::from(visible)))
51}
52
53#[utoipa::path(
57 get,
58 path = "/{package}/latest",
59 tag = "docs",
60 params(
61 ("package" = String, Path, description = "Package name")
62 ),
63 responses(
64 (status = 302, description = "Redirect to latest documentation")
65 ),
66 security(("session_cookie" = []))
67)]
68pub async fn latest_docs(
69 Path(package): Path<OriginalName>,
70 user: Result<MaybeUser, StatusCode>,
71 State(settings): SettingsState,
72 State(docs_storage): DocsStorageState,
73 State(db): DbState,
74) -> Redirect {
75 let name = package.to_normalized();
76
77 if !can_read_docs(&name, user.ok().as_ref(), &db).await {
79 return Redirect::temporary("/");
80 }
81
82 let opt_doc_version = get_latest_version_with_doc(&name, &docs_storage).await;
83 let res_db_version = db.get_max_version_from_name(&name).await;
84
85 if let Some(doc_version) = opt_doc_version
86 && let Ok(db_version) = res_db_version
87 && doc_version == db_version
88 {
89 return Redirect::temporary(&compute_doc_url(&name, &db_version, &settings.origin.path));
90 }
91
92 Redirect::temporary("/")
93}
94
95async fn can_read_docs(
97 crate_name: &NormalizedName,
98 user: Option<&MaybeUser>,
99 db: &Arc<dyn DbProvider>,
100) -> bool {
101 can_read_crate(crate_name, user, db)
102 .await
103 .unwrap_or_else(|e| {
104 error!("Failed to check docs access for {crate_name}: {e}");
105 false
106 })
107}
108
109#[utoipa::path(
114 put,
115 path = "/{package}/{version}",
116 tag = "docs",
117 params(
118 ("package" = String, Path, description = "Package name"),
119 ("version" = String, Path, description = "Package version")
120 ),
121 request_body(content = Vec<u8>, description = "Documentation archive (tar.gz or zip)", content_type = "application/octet-stream"),
122 responses(
123 (status = 200, description = "Documentation published successfully", body = DocUploadResponse),
124 (status = 400, description = "Crate or version does not exist"),
125 (status = 401, description = "Not authorized"),
126 (status = 403, description = "Not an owner of the crate")
127 ),
128 security(("cargo_token" = []))
129)]
130pub async fn publish_docs(
131 Path((package, version)): Path<(OriginalName, Version)>,
132 token: Token,
133 State(state): AppState,
134 mut docs: DocArchive,
135) -> ApiResult<Json<DocUploadResponse>> {
136 let db = state.db;
137 let settings = state.settings;
138 let docs_storage = state.docs_storage;
139 let normalized_name = package.to_normalized();
140 let crate_version = &version.to_string();
141
142 let user = MaybeUser::from_token(token);
147 check_can_modify(&user)?;
148 check_ownership(&normalized_name, &user, &db).await?;
149
150 if let Some(id) = db.get_crate_id(&normalized_name).await? {
152 if !db.crate_version_exists(id, crate_version).await? {
153 return crate_does_not_exist(&normalized_name, crate_version);
154 }
155 } else {
156 return crate_does_not_exist(&normalized_name, crate_version);
157 }
158
159 let tmp_dir = tempfile::tempdir().map_err(DocsError::IoError)?;
160 let extract_path = tmp_dir.path().to_path_buf();
161
162 tokio::task::spawn_blocking(move || docs.extract(&extract_path))
163 .await
164 .map_err(|_| DocsError::ExtractFailed)??;
165
166 upload_dir_and_prune(tmp_dir.path(), "", &package, crate_version, &docs_storage).await?;
167
168 db.update_docs_link(
169 &normalized_name,
170 &version,
171 &compute_doc_url(&package, &version, &settings.origin.path),
172 )
173 .await?;
174
175 Ok(Json(DocUploadResponse::new(
176 "Successfully published docs.".to_string(),
177 &package,
178 &version,
179 &settings.origin.path,
180 )))
181}
182
183fn crate_does_not_exist(
184 crate_name: &str,
185 crate_version: &str,
186) -> ApiResult<Json<DocUploadResponse>> {
187 Err(DocsError::CrateDoesNotExist(crate_name.to_string(), crate_version.to_string()).into())
188}
189
190#[cfg(test)]
191mod tests {
192 use std::io::Write;
193 use std::path::PathBuf;
194 use std::sync::Arc;
195
196 use axum::Router;
197 use axum::body::Body;
198 use axum::http::{Request, StatusCode, header};
199 use axum::routing::{get, put};
200 use http_body_util::BodyExt;
201 use kellnr_appstate::AppStateData;
202 use kellnr_common::normalized_name::NormalizedName;
203 use kellnr_db::error::DbError;
204 use kellnr_db::mock::MockDb;
205 use kellnr_db::{DbProvider, DocQueueEntry, User};
206 use kellnr_storage::cached_crate_storage::DynStorage;
207 use kellnr_storage::docs_storage::DocsStorage;
208 use kellnr_storage::fs_storage::FSStorage;
209 use mockall::predicate::eq;
210 use tower::ServiceExt;
211
212 use super::*;
213 use crate::doc_queue_response::DocQueueEntryResponse;
214
215 #[tokio::test]
216 async fn doc_in_queue_returns_queue_entries() {
217 let mut db = MockDb::new();
218 db.expect_is_download_restricted().returning(|_| Ok(false));
219 db.expect_get_doc_queue().returning(|| {
220 Ok(vec![
221 DocQueueEntry {
222 id: 0,
223 normalized_name: NormalizedName::from_unchecked("crate1".to_string()),
224 version: "0.0.1".to_string(),
225 path: PathBuf::default(),
226 },
227 DocQueueEntry {
228 id: 1,
229 normalized_name: NormalizedName::from_unchecked("crate2".to_string()),
230 version: "0.0.2".to_string(),
231 path: PathBuf::default(),
232 },
233 ])
234 });
235
236 let kellnr = app(Arc::new(db));
237 let r = kellnr
238 .oneshot(Request::get("/queue").body(Body::empty()).unwrap())
239 .await
240 .unwrap();
241
242 let actual = r.into_body().collect().await.unwrap().to_bytes();
243 let actual = serde_json::from_slice::<DocQueueResponse>(&actual).unwrap();
244 assert_eq!(
245 DocQueueResponse {
246 queue: vec![
247 DocQueueEntryResponse {
248 name: "crate1".to_string(),
249 version: "0.0.1".to_string()
250 },
251 DocQueueEntryResponse {
252 name: "crate2".to_string(),
253 version: "0.0.2".to_string()
254 }
255 ]
256 },
257 actual
258 );
259 }
260
261 fn queue_entry(id: i64, name: &str) -> DocQueueEntry {
262 DocQueueEntry {
263 id,
264 normalized_name: NormalizedName::from_unchecked(name.to_string()),
265 version: "1.0.0".to_string(),
266 path: PathBuf::default(),
267 }
268 }
269
270 fn db_with_restricted_queue_entry() -> MockDb {
272 let mut db = MockDb::new();
273 db.expect_get_doc_queue()
274 .returning(|| Ok(vec![queue_entry(0, "public"), queue_entry(1, "secret")]));
275 db.expect_is_download_restricted()
276 .returning(|name| Ok(name.to_string() == "secret"));
277 db
278 }
279
280 async fn queued_names(db: MockDb, token: Option<&str>) -> Vec<String> {
281 let mut request = Request::get("/queue");
282 if let Some(token) = token {
283 request = request.header(header::AUTHORIZATION, token);
284 }
285 let r = app(Arc::new(db))
286 .oneshot(request.body(Body::empty()).unwrap())
287 .await
288 .unwrap();
289 let body = r.into_body().collect().await.unwrap().to_bytes();
290 serde_json::from_slice::<DocQueueResponse>(&body)
291 .unwrap()
292 .queue
293 .into_iter()
294 .map(|e| e.name)
295 .collect()
296 }
297
298 #[tokio::test]
299 async fn doc_queue_hides_restricted_crate_from_anonymous_user() {
300 let names = queued_names(db_with_restricted_queue_entry(), None).await;
301
302 assert_eq!(names, vec!["public"]);
303 }
304
305 #[tokio::test]
306 async fn doc_queue_hides_restricted_crate_from_user_without_access() {
307 let mut db = db_with_restricted_queue_entry();
308 expect_token_user(&mut db, "bob");
309 db.expect_is_crate_user().returning(|_, _| Ok(false));
310 db.expect_is_crate_group_user().returning(|_, _| Ok(false));
311 db.expect_is_owner().returning(|_, _| Ok(false));
312
313 let names = queued_names(db, Some("token")).await;
314
315 assert_eq!(names, vec!["public"]);
316 }
317
318 #[tokio::test]
319 async fn doc_queue_lists_restricted_crate_for_crate_user() {
320 let mut db = db_with_restricted_queue_entry();
321 expect_token_user(&mut db, "alice");
322 db.expect_is_crate_user()
323 .returning(|_, user| Ok(user == "alice"));
324
325 let names = queued_names(db, Some("token")).await;
326
327 assert_eq!(names, vec!["public", "secret"]);
328 }
329
330 fn app(db: Arc<dyn DbProvider>) -> Router {
331 Router::new()
332 .route("/queue", get(docs_in_queue))
333 .with_state(AppStateData {
334 db,
335 ..kellnr_appstate::test_state()
336 })
337 }
338
339 fn build_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
340 let mut buf = Vec::new();
341 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
342 let options = zip::write::SimpleFileOptions::default();
343 for (name, content) in files {
344 zip.start_file(*name, options).unwrap();
345 zip.write_all(content).unwrap();
346 }
347 zip.finish().unwrap();
348 buf
349 }
350
351 fn publish_app(db: Arc<dyn DbProvider>, docs_storage: Arc<DocsStorage>) -> Router {
352 Router::new()
353 .route("/{package}/{version}", put(publish_docs))
354 .with_state(AppStateData {
355 db,
356 docs_storage,
357 ..kellnr_appstate::test_state()
358 })
359 }
360
361 fn expect_admin_owner(db: &mut MockDb, normalized: &NormalizedName) {
362 db.expect_get_crate_id()
363 .with(eq(normalized.clone()))
364 .returning(|_| Ok(Some(1)));
365 db.expect_crate_version_exists()
366 .with(eq(1), eq("1.0.0"))
367 .returning(|_, _| Ok(true));
368 db.expect_get_user_from_token().returning(|_| {
369 Ok(User {
370 is_admin: true,
371 name: "admin".to_string(),
372 ..User::default()
373 })
374 });
375 }
376
377 #[tokio::test]
378 async fn publish_docs_uploads_files_and_updates_docs_link() {
379 let normalized = NormalizedName::from_unchecked("test-crate".to_string());
380 let mut db = MockDb::new();
381 expect_admin_owner(&mut db, &normalized);
382 db.expect_update_docs_link()
383 .with(
384 eq(normalized.clone()),
385 eq(Version::try_from("1.0.0").unwrap()),
386 eq("/docs/test-crate/1.0.0/doc/test_crate/index.html".to_string()),
387 )
388 .returning(|_, _, _| Ok(()));
389
390 let tmp = tempfile::tempdir().unwrap();
391 let docs_storage = Arc::new(DocsStorage::new(Box::new(
392 FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
393 ) as DynStorage));
394
395 let kellnr = publish_app(Arc::new(db), docs_storage.clone());
396 let zip_bytes = build_zip(&[("doc/test_crate/index.html", b"<html>docs</html>")]);
397
398 let r = kellnr
399 .oneshot(
400 Request::put("/test-crate/1.0.0")
401 .header(header::AUTHORIZATION, "sometoken")
402 .body(Body::from(zip_bytes))
403 .unwrap(),
404 )
405 .await
406 .unwrap();
407
408 assert_eq!(r.status(), StatusCode::OK);
409
410 let key = DocsStorage::file_key("test-crate", "1.0.0", "doc/test_crate/index.html");
411 let stored = docs_storage.get(&key).await.unwrap();
412 assert_eq!(stored, bytes::Bytes::from_static(b"<html>docs</html>"));
413 }
414
415 #[tokio::test]
416 async fn publish_docs_rejects_read_only_owner() {
417 let normalized = NormalizedName::from_unchecked("test-crate".to_string());
418 let mut db = MockDb::new();
419 db.expect_get_crate_id()
420 .with(eq(normalized.clone()))
421 .returning(|_| Ok(Some(1)));
422 db.expect_crate_version_exists()
423 .with(eq(1), eq("1.0.0"))
424 .returning(|_, _| Ok(true));
425 db.expect_get_user_from_token().returning(|_| {
426 Ok(User {
427 name: "reader".to_string(),
428 is_read_only: true,
429 ..User::default()
430 })
431 });
432 db.expect_is_owner().returning(|_, _| Ok(true));
433 db.expect_update_docs_link().never();
434
435 let tmp = tempfile::tempdir().unwrap();
436 let docs_storage = Arc::new(DocsStorage::new(Box::new(
437 FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
438 ) as DynStorage));
439
440 let kellnr = publish_app(Arc::new(db), docs_storage.clone());
441 let zip_bytes = build_zip(&[("doc/test_crate/index.html", b"<html>docs</html>")]);
442
443 let r = kellnr
444 .oneshot(
445 Request::put("/test-crate/1.0.0")
446 .header(header::AUTHORIZATION, "sometoken")
447 .body(Body::from(zip_bytes))
448 .unwrap(),
449 )
450 .await
451 .unwrap();
452
453 assert_eq!(r.status(), StatusCode::BAD_REQUEST);
455 let key = DocsStorage::file_key("test-crate", "1.0.0", "doc/test_crate/index.html");
456 assert!(!docs_storage.exists(&key).await.unwrap());
457 }
458
459 #[tokio::test]
462 async fn publish_docs_non_owner_cannot_tell_missing_from_existing_crate() {
463 let mut db = MockDb::new();
464 db.expect_get_crate_id()
465 .returning(|name| Ok((name.as_str() == "test-crate").then_some(1)));
466 db.expect_crate_version_exists()
467 .returning(|_, version| Ok(version == "1.0.0"));
468 db.expect_get_user_from_token().returning(|_| {
469 Ok(User {
470 name: "other".to_string(),
471 ..User::default()
472 })
473 });
474 db.expect_is_owner().returning(|_, _| Ok(false));
475 db.expect_update_docs_link().never();
476 let db: Arc<dyn DbProvider> = Arc::new(db);
477
478 let tmp = tempfile::tempdir().unwrap();
479 let docs_storage = Arc::new(DocsStorage::new(Box::new(
480 FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
481 ) as DynStorage));
482
483 for uri in ["/test-crate/1.0.0", "/test-crate/9.9.9", "/missing/1.0.0"] {
484 let zip_bytes = build_zip(&[("doc/x/index.html", b"<html>docs</html>")]);
485 let r = publish_app(db.clone(), docs_storage.clone())
486 .oneshot(
487 Request::put(uri)
488 .header(header::AUTHORIZATION, "sometoken")
489 .body(Body::from(zip_bytes))
490 .unwrap(),
491 )
492 .await
493 .unwrap();
494 assert_eq!(r.status(), StatusCode::FORBIDDEN, "{uri}");
495 }
496 }
497
498 #[tokio::test]
499 async fn publish_docs_propagates_extraction_failure() {
500 let normalized = NormalizedName::from_unchecked("test-crate".to_string());
505 let mut db = MockDb::new();
506 expect_admin_owner(&mut db, &normalized);
507
508 let tmp = tempfile::tempdir().unwrap();
509 let docs_storage = Arc::new(DocsStorage::new(Box::new(
510 FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
511 ) as DynStorage));
512
513 let kellnr = publish_app(Arc::new(db), docs_storage);
514 let zip_bytes = build_zip(&[("../escape.html", b"evil")]);
515
516 let r = kellnr
517 .oneshot(
518 Request::put("/test-crate/1.0.0")
519 .header(header::AUTHORIZATION, "sometoken")
520 .body(Body::from(zip_bytes))
521 .unwrap(),
522 )
523 .await
524 .unwrap();
525
526 assert_eq!(r.status(), StatusCode::BAD_REQUEST);
529 }
530
531 fn latest_app(db: Arc<dyn DbProvider>, docs_storage: Arc<DocsStorage>) -> Router {
532 Router::new()
533 .route("/{package}/latest", get(latest_docs))
534 .with_state(AppStateData {
535 db,
536 docs_storage,
537 ..kellnr_appstate::test_state()
538 })
539 }
540
541 async fn docs_storage_with_latest_docs() -> (tempfile::TempDir, Arc<DocsStorage>) {
542 let tmp = tempfile::tempdir().unwrap();
543 let docs_storage = Arc::new(DocsStorage::new(Box::new(
544 FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
545 ) as DynStorage));
546 let key = DocsStorage::file_key("secret", "1.0.0", "doc/secret/index.html");
547 docs_storage
548 .put(&key, bytes::Bytes::from_static(b"<html>docs</html>"))
549 .await
550 .unwrap();
551 (tmp, docs_storage)
552 }
553
554 fn expect_restricted_crate(db: &mut MockDb, restricted: bool) {
555 db.expect_is_download_restricted()
556 .returning(move |_| Ok(restricted));
557 db.expect_get_max_version_from_name()
558 .returning(|_| Ok(Version::try_from("1.0.0").unwrap()));
559 }
560
561 fn expect_token_user(db: &mut MockDb, name: &'static str) {
562 db.expect_get_user_from_token().returning(move |_| {
563 Ok(User {
564 name: name.to_string(),
565 ..User::default()
566 })
567 });
568 }
569
570 async fn latest_location(
571 db: MockDb,
572 docs_storage: Arc<DocsStorage>,
573 token: Option<&str>,
574 ) -> String {
575 let mut request = Request::get("/secret/latest");
576 if let Some(token) = token {
577 request = request.header(header::AUTHORIZATION, token);
578 }
579 let r = latest_app(Arc::new(db), docs_storage)
580 .oneshot(request.body(Body::empty()).unwrap())
581 .await
582 .unwrap();
583 assert_eq!(r.status(), StatusCode::TEMPORARY_REDIRECT);
584 r.headers()[header::LOCATION].to_str().unwrap().to_string()
585 }
586
587 #[tokio::test]
588 async fn latest_docs_redirects_to_docs_for_unrestricted_crate() {
589 let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
590 let mut db = MockDb::new();
591 expect_restricted_crate(&mut db, false);
592
593 let location = latest_location(db, docs_storage, None).await;
594
595 assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
596 }
597
598 #[tokio::test]
599 async fn latest_docs_hides_restricted_crate_from_anonymous_user() {
600 let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
601 let mut db = MockDb::new();
602 expect_restricted_crate(&mut db, true);
603
604 let location = latest_location(db, docs_storage, None).await;
605
606 assert_eq!(location, "/");
607 }
608
609 #[tokio::test]
610 async fn latest_docs_hides_restricted_crate_from_user_without_access() {
611 let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
612 let mut db = MockDb::new();
613 expect_restricted_crate(&mut db, true);
614 expect_token_user(&mut db, "bob");
615 db.expect_is_crate_user().returning(|_, _| Ok(false));
616 db.expect_is_crate_group_user().returning(|_, _| Ok(false));
617 db.expect_is_owner().returning(|_, _| Ok(false));
618
619 let location = latest_location(db, docs_storage, Some("token")).await;
620
621 assert_eq!(location, "/");
622 }
623
624 #[tokio::test]
625 async fn latest_docs_redirects_crate_user_of_restricted_crate() {
626 let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
627 let mut db = MockDb::new();
628 expect_restricted_crate(&mut db, true);
629 expect_token_user(&mut db, "alice");
630 db.expect_is_crate_user()
631 .with(
632 eq(NormalizedName::from_unchecked("secret".to_string())),
633 eq("alice"),
634 )
635 .returning(|_, _| Ok(true));
636
637 let location = latest_location(db, docs_storage, Some("token")).await;
638
639 assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
640 }
641
642 #[tokio::test]
643 async fn latest_docs_redirects_owner_of_restricted_crate() {
644 let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
645 let mut db = MockDb::new();
646 expect_restricted_crate(&mut db, true);
647 expect_token_user(&mut db, "olivia");
648 db.expect_is_crate_user().returning(|_, _| Ok(false));
649 db.expect_is_crate_group_user().returning(|_, _| Ok(false));
650 db.expect_is_owner()
651 .with(
652 eq(NormalizedName::from_unchecked("secret".to_string())),
653 eq("olivia"),
654 )
655 .returning(|_, _| Ok(true));
656
657 let location = latest_location(db, docs_storage, Some("token")).await;
658
659 assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
660 }
661
662 #[tokio::test]
663 async fn latest_docs_redirects_group_member_of_restricted_crate() {
664 let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
665 let mut db = MockDb::new();
666 expect_restricted_crate(&mut db, true);
667 expect_token_user(&mut db, "greg");
668 db.expect_is_crate_user().returning(|_, _| Ok(false));
669 db.expect_is_crate_group_user()
670 .with(
671 eq(NormalizedName::from_unchecked("secret".to_string())),
672 eq("greg"),
673 )
674 .returning(|_, _| Ok(true));
675
676 let location = latest_location(db, docs_storage, Some("token")).await;
677
678 assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
679 }
680
681 #[tokio::test]
682 async fn latest_docs_hides_crate_when_restriction_check_fails() {
683 let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
684 let mut db = MockDb::new();
685 db.expect_is_download_restricted()
686 .returning(|_| Err(DbError::CrateNotFound("secret".to_string())));
687
688 let location = latest_location(db, docs_storage, None).await;
689
690 assert_eq!(location, "/");
691 }
692
693 #[tokio::test]
694 async fn latest_docs_hides_restricted_crate_when_access_check_fails() {
695 let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
696 let mut db = MockDb::new();
697 expect_restricted_crate(&mut db, true);
698 expect_token_user(&mut db, "alice");
699 db.expect_is_crate_user()
700 .returning(|_, _| Err(DbError::CrateNotFound("secret".to_string())));
701
702 let location = latest_location(db, docs_storage, Some("token")).await;
703
704 assert_eq!(location, "/");
705 }
706}