Skip to main content

kellnr_docs/
api.rs

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/// Get documentation build queue
27///
28/// Returns the list of crates currently in the documentation build queue.
29/// Download-restricted crates are only listed for users who may read their docs.
30#[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/// Redirect to latest documentation
54///
55/// Redirects to the latest documentation for a given package.
56#[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    // Same response as "no docs" to not reveal that a restricted crate exists.
78    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
95/// Whether `user` may read the docs of a crate, honoring its download restriction.
96async 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/// Publish documentation for a crate version
110///
111/// Upload documentation for a specific crate and version.
112/// Requires ownership of the crate (via cargo token).
113#[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    // Check if user from token is an owner of the crate.
143    // If not, he is not allowed to push the docs. This comes before the
144    // version check, so that others cannot tell which crates and versions
145    // exist: they get the same error for a missing crate.
146    let user = MaybeUser::from_token(token);
147    check_can_modify(&user)?;
148    check_ownership(&normalized_name, &user, &db).await?;
149
150    // Check if crate with the version exists.
151    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    /// Queue with the unrestricted crate `public` and the restricted crate `secret`.
271    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        // Read-only users are rejected with 400, like on all other modifying endpoints.
454        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    // A non-owner gets the same response for a missing crate or version as for
460    // an existing crate, so that the existence of a crate is not revealed.
461    #[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        // A well-formed zip whose only entry escapes the extraction directory. The
501        // archive parses fine (`ZipArchive::new` succeeds) but `ZipArchive::extract`
502        // rejects the unsafe path, so the inner `Result` from `docs.extract(..)` must
503        // reach the caller as an error (previously silently discarded, returning 200).
504        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        // Previously this error was silently discarded (`let _ = ...`), returning 200
527        // even though extraction failed. It must now surface as a client error.
528        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}