Skip to main content

kellnr_docs/
api.rs

1use std::sync::Arc;
2
3use axum::Json;
4use axum::extract::{Path, State};
5use axum::response::Redirect;
6use kellnr_common::binary_body::BinaryBody;
7use kellnr_common::canonical_name::CanonicalName;
8use kellnr_common::original_name::OriginalName;
9use kellnr_common::version::Version;
10use kellnr_db::DbProvider;
11use kellnr_http::authz::{can_read_crate, require_owner, require_writable};
12use kellnr_http::error::ApiResult;
13use kellnr_http::identity::{AnyUser, Identity, TokenUser};
14use kellnr_http::state::{AppState, DbState, DocsStorageState, SettingsState};
15use kellnr_storage::docs_storage::DocsName;
16use tracing::error;
17
18use crate::doc_archive::DocArchive;
19use crate::doc_queue_response::DocQueueResponse;
20use crate::docs_error::DocsError;
21use crate::upload::upload_dir_and_prune;
22use crate::upload_response::DocUploadResponse;
23use crate::{compute_doc_url, doc_exists};
24
25/// Get documentation build queue
26///
27/// Returns the list of crates currently in the documentation build queue.
28/// Download-restricted crates are only listed for users who may read their docs.
29#[utoipa::path(
30    get,
31    path = "/builds",
32    tag = "docs",
33    responses(
34        (status = 200, description = "Documentation build queue", body = DocQueueResponse)
35    ),
36    security(("session_cookie" = []))
37)]
38pub async fn docs_in_queue(
39    user: Option<AnyUser>,
40    State(db): DbState,
41) -> ApiResult<Json<DocQueueResponse>> {
42    let mut visible = Vec::new();
43    for entry in db.get_doc_queue().await? {
44        let Ok(name) = entry.canonical_name() else {
45            continue;
46        };
47        if can_read_docs(&name, user.as_deref(), &db).await {
48            visible.push(entry);
49        }
50    }
51    Ok(Json(DocQueueResponse::from(visible)))
52}
53
54/// Redirect to latest documentation
55///
56/// Redirects to the latest documentation for a given package.
57#[utoipa::path(
58    get,
59    path = "/{package}/latest",
60    tag = "docs",
61    params(
62        ("package" = String, Path, description = "Package name")
63    ),
64    responses(
65        (status = 302, description = "Redirect to latest documentation")
66    ),
67    security(("session_cookie" = []))
68)]
69pub async fn latest_docs(
70    Path(package): Path<OriginalName>,
71    user: Option<AnyUser>,
72    State(settings): SettingsState,
73    State(docs_storage): DocsStorageState,
74    State(db): DbState,
75) -> Redirect {
76    let name = package.to_canonical();
77
78    // Same response as "no docs" to not reveal that a restricted crate exists.
79    if !can_read_docs(&name, user.as_deref(), &db).await {
80        return Redirect::temporary("/");
81    }
82
83    if let Ok(version) = db.get_max_version_from_name(&name).await
84        && let Ok(Some(original_name)) = db.get_original_name(&name, &version).await
85        && doc_exists(&original_name, &version, &docs_storage).await
86    {
87        return Redirect::temporary(&compute_doc_url(
88            &original_name,
89            &version,
90            &settings.origin.path_prefix(),
91        ));
92    }
93
94    Redirect::temporary("/")
95}
96
97/// Whether `user` may read the docs of a crate, honoring its download restriction.
98async fn can_read_docs(
99    crate_name: &CanonicalName,
100    user: Option<&Identity>,
101    db: &Arc<dyn DbProvider>,
102) -> bool {
103    can_read_crate(crate_name, user, db)
104        .await
105        .unwrap_or_else(|e| {
106            error!("Failed to check docs access for {crate_name}: {e}");
107            false
108        })
109}
110
111/// Publish documentation for a crate version
112///
113/// Upload documentation for a specific crate and version.
114/// Requires ownership of the crate (via cargo token).
115#[utoipa::path(
116    put,
117    path = "/{package}/{version}",
118    tag = "docs",
119    params(
120        ("package" = String, Path, description = "Package name"),
121        ("version" = String, Path, description = "Package version")
122    ),
123    request_body(content = inline(BinaryBody), description = "Documentation archive (tar.gz or zip)", content_type = "application/octet-stream"),
124    responses(
125        (status = 200, description = "Documentation published successfully", body = DocUploadResponse),
126        (status = 400, description = "Crate or version does not exist"),
127        (status = 401, description = "Not authorized"),
128        (status = 403, description = "Not an owner of the crate")
129    ),
130    security(("cargo_token" = []))
131)]
132pub async fn publish_docs(
133    Path((package, version)): Path<(OriginalName, Version)>,
134    user: TokenUser,
135    State(state): AppState,
136    mut docs: DocArchive,
137) -> ApiResult<Json<DocUploadResponse>> {
138    let db = state.db;
139    let settings = state.settings;
140    let docs_storage = state.docs_storage;
141    let canonical_name = package.to_canonical();
142
143    // Check if user from token is an owner of the crate.
144    // If not, he is not allowed to push the docs. This comes before the
145    // version check, so that others cannot tell which crates and versions
146    // exist: they get the same error for a missing crate.
147    require_writable(&user)?;
148    require_owner(&canonical_name, &user, &db).await?;
149
150    let Some(original_name) = db.get_original_name(&canonical_name, &version).await? else {
151        return crate_does_not_exist(&package, &version);
152    };
153
154    let tmp_dir = tempfile::tempdir().map_err(DocsError::IoError)?;
155    let extract_path = tmp_dir.path().to_path_buf();
156
157    tokio::task::spawn_blocking(move || docs.extract(&extract_path))
158        .await
159        .map_err(|_| DocsError::ExtractFailed)??;
160
161    upload_dir_and_prune(
162        tmp_dir.path(),
163        "",
164        &DocsName::from(&original_name),
165        &version,
166        &docs_storage,
167    )
168    .await?;
169
170    db.update_docs_link(
171        &canonical_name,
172        &version,
173        &compute_doc_url(&original_name, &version, &settings.origin.path_prefix()),
174    )
175    .await?;
176
177    Ok(Json(DocUploadResponse::new(
178        "Successfully published docs.".to_string(),
179        &original_name,
180        &version,
181        &settings.origin.path_prefix(),
182    )))
183}
184
185fn crate_does_not_exist(
186    crate_name: &OriginalName,
187    crate_version: &Version,
188) -> ApiResult<Json<DocUploadResponse>> {
189    Err(DocsError::CrateDoesNotExist(crate_name.to_lowercase(), crate_version.to_string()).into())
190}
191
192#[cfg(test)]
193mod tests {
194    use std::io::Write;
195    use std::path::PathBuf;
196    use std::sync::Arc;
197
198    use axum::Router;
199    use axum::body::Body;
200    use axum::http::{Request, StatusCode, header};
201    use axum::routing::{get, put};
202    use http_body_util::BodyExt;
203    use kellnr_common::account_name::{GroupName, UserName};
204    use kellnr_common::canonical_name::CanonicalName;
205    use kellnr_common::plain_token::PlainToken;
206    use kellnr_db::UserFlags;
207    use kellnr_db::test_utils::{TestCrateBuilder, test_drop_table};
208    use kellnr_http::state::AppStateData;
209    use kellnr_http::test_support::sqlite_test_state;
210    use kellnr_storage::cached_crate_storage::DynStorage;
211    use kellnr_storage::docs_storage::DocsStorage;
212    use kellnr_storage::fs_storage::FSStorage;
213    use kellnr_storage::object_storage::MemoryStorage;
214    use kellnr_test_db::TestDb;
215    use tower::ServiceExt;
216
217    use super::*;
218    use crate::doc_queue_response::DocQueueEntryResponse;
219
220    /// Token of the user `admin` in every test database.
221    const ADMIN_TOKEN: &str = "token";
222
223    /// The docs API routes at their paths in the API, on `state` with `docs_storage`.
224    fn app(state: AppStateData, docs_storage: Arc<DocsStorage>) -> Router {
225        Router::new()
226            .route("/builds", get(docs_in_queue))
227            .route("/{package}/latest", get(latest_docs))
228            .route("/{package}/{version}", put(publish_docs))
229            .with_state(AppStateData {
230                docs_storage,
231                ..state
232            })
233    }
234
235    fn memory_docs_storage() -> Arc<DocsStorage> {
236        Arc::new(DocsStorage::new(
237            Box::new(MemoryStorage::new()) as DynStorage
238        ))
239    }
240
241    fn canonical(name: &str) -> CanonicalName {
242        CanonicalName::try_from(name).unwrap()
243    }
244
245    fn user_name(name: &str) -> UserName {
246        UserName::new(name).unwrap()
247    }
248
249    async fn add_crate(db: &TestDb, name: &str, version: &str) {
250        TestCrateBuilder::new(db)
251            .name(name)
252            .version(version)
253            .build()
254            .await
255            .unwrap();
256    }
257
258    /// Adds the crate `name`, which is download-restricted if `restricted`.
259    async fn add_restricted_crate(db: &TestDb, name: &str, restricted: bool) {
260        add_crate(db, name, "1.0.0").await;
261        db.change_download_restricted(&canonical(name), restricted)
262            .await
263            .unwrap();
264    }
265
266    /// Adds the user `name` with `flags` and returns a new cargo token of it.
267    async fn token_user(db: &TestDb, name: &str, flags: UserFlags) -> String {
268        db.add_user(&user_name(name), "pwd", flags).await.unwrap();
269        let token = format!("{name}-token");
270        db.add_auth_token("test", &PlainToken::from(token.as_str()), &user_name(name))
271            .await
272            .unwrap();
273        token
274    }
275
276    async fn add_doc_queue(db: &TestDb, name: &str, version: &str) {
277        db.add_doc_queue(
278            &OriginalName::try_from(name).unwrap(),
279            &Version::try_from(version).unwrap(),
280            &PathBuf::default(),
281        )
282        .await
283        .unwrap();
284    }
285
286    async fn docs_link(db: &TestDb, name: &str) -> Option<String> {
287        let data = db.get_crate_data(&canonical(name)).await.unwrap();
288        data.versions[0].documentation.clone()
289    }
290
291    #[tokio::test]
292    async fn doc_in_queue_returns_queue_entries() {
293        let (state, db) = sqlite_test_state().await;
294        add_crate(&db, "crate1", "0.0.1").await;
295        add_crate(&db, "crate2", "0.0.2").await;
296        add_doc_queue(&db, "crate1", "0.0.1").await;
297        add_doc_queue(&db, "crate2", "0.0.2").await;
298
299        let r = app(state, memory_docs_storage())
300            .oneshot(Request::get("/builds").body(Body::empty()).unwrap())
301            .await
302            .unwrap();
303
304        let actual = r.into_body().collect().await.unwrap().to_bytes();
305        let actual = serde_json::from_slice::<DocQueueResponse>(&actual).unwrap();
306        assert_eq!(
307            DocQueueResponse {
308                queue: vec![
309                    DocQueueEntryResponse {
310                        name: "crate1".to_string(),
311                        version: "0.0.1".to_string()
312                    },
313                    DocQueueEntryResponse {
314                        name: "crate2".to_string(),
315                        version: "0.0.2".to_string()
316                    }
317                ]
318            },
319            actual
320        );
321    }
322
323    /// App state with the unrestricted crate `public` and the restricted
324    /// crate `secret` in the docs queue.
325    async fn state_with_restricted_queue_entry() -> (AppStateData, TestDb) {
326        let (state, db) = sqlite_test_state().await;
327        add_restricted_crate(&db, "public", false).await;
328        add_restricted_crate(&db, "secret", true).await;
329        add_doc_queue(&db, "public", "1.0.0").await;
330        add_doc_queue(&db, "secret", "1.0.0").await;
331        (state, db)
332    }
333
334    async fn queued_names(state: AppStateData, token: Option<&str>) -> Vec<String> {
335        let mut request = Request::get("/builds");
336        if let Some(token) = token {
337            request = request.header(header::AUTHORIZATION, token);
338        }
339        let r = app(state, memory_docs_storage())
340            .oneshot(request.body(Body::empty()).unwrap())
341            .await
342            .unwrap();
343        let body = r.into_body().collect().await.unwrap().to_bytes();
344        serde_json::from_slice::<DocQueueResponse>(&body)
345            .unwrap()
346            .queue
347            .into_iter()
348            .map(|e| e.name)
349            .collect()
350    }
351
352    #[tokio::test]
353    async fn doc_queue_hides_restricted_crate_from_anonymous_user() {
354        let (state, _db) = state_with_restricted_queue_entry().await;
355
356        let names = queued_names(state, None).await;
357
358        assert_eq!(names, vec!["public"]);
359    }
360
361    #[tokio::test]
362    async fn doc_queue_hides_restricted_crate_from_user_without_access() {
363        let (state, db) = state_with_restricted_queue_entry().await;
364        let token = token_user(&db, "bob", UserFlags::normal()).await;
365
366        let names = queued_names(state, Some(&token)).await;
367
368        assert_eq!(names, vec!["public"]);
369    }
370
371    #[tokio::test]
372    async fn doc_queue_lists_restricted_crate_for_crate_user() {
373        let (state, db) = state_with_restricted_queue_entry().await;
374        let token = token_user(&db, "alice", UserFlags::normal()).await;
375        db.add_crate_user(&canonical("secret"), &user_name("alice"))
376            .await
377            .unwrap();
378
379        let names = queued_names(state, Some(&token)).await;
380
381        assert_eq!(names, vec!["public", "secret"]);
382    }
383
384    fn build_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
385        let mut buf = Vec::new();
386        let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
387        let options = zip::write::SimpleFileOptions::default();
388        for (name, content) in files {
389            zip.start_file(*name, options).unwrap();
390            zip.write_all(content).unwrap();
391        }
392        zip.finish().unwrap();
393        buf
394    }
395
396    fn fs_docs_storage(dir: &tempfile::TempDir) -> Arc<DocsStorage> {
397        Arc::new(DocsStorage::new(
398            Box::new(FSStorage::new(dir.path().to_str().unwrap()).unwrap()) as DynStorage,
399        ))
400    }
401
402    fn publish_request(uri: &str, token: &str, zip: Vec<u8>) -> Request<Body> {
403        Request::put(uri)
404            .header(header::AUTHORIZATION, token)
405            .body(Body::from(zip))
406            .unwrap()
407    }
408
409    #[tokio::test]
410    async fn publish_docs_stores_docs_under_lowercase_name_and_links_rustdoc_folder() {
411        let (state, db) = sqlite_test_state().await;
412        add_crate(&db, "My-Crate", "1.0.0").await;
413        let docs_storage = memory_docs_storage();
414        let zip = build_zip(&[("doc/My_Crate/index.html", b"<html>docs</html>")]);
415
416        let r = app(state, docs_storage.clone())
417            .oneshot(publish_request("/MY-crate/1.0.0", ADMIN_TOKEN, zip))
418            .await
419            .unwrap();
420
421        assert_eq!(r.status(), StatusCode::OK);
422        let key = DocsStorage::file_key(
423            &docs_name("my-crate"),
424            &Version::try_from("1.0.0").unwrap(),
425            "doc/My_Crate/index.html",
426        );
427        assert!(docs_storage.exists(&key).await.unwrap());
428        let body = r.into_body().collect().await.unwrap().to_bytes();
429        let response: DocUploadResponse = serde_json::from_slice(&body).unwrap();
430        assert_eq!(response.crate_name, "My-Crate");
431        assert_eq!(
432            docs_link(&db, "my-crate").await.as_deref(),
433            Some("/docs/my-crate/1.0.0/doc/My_Crate/index.html")
434        );
435    }
436
437    // Docs stay under the lowercase name the crate was published with, also
438    // if they are uploaded under another spelling of the crate name.
439    #[tokio::test]
440    async fn publish_docs_under_other_separator_stores_docs_under_published_name() {
441        let (state, db) = sqlite_test_state().await;
442        add_crate(&db, "My-Crate", "1.0.0").await;
443        let docs_storage = memory_docs_storage();
444        let zip = build_zip(&[("doc/My_Crate/index.html", b"<html>docs</html>")]);
445
446        let r = app(state, docs_storage.clone())
447            .oneshot(publish_request("/my_crate/1.0.0", ADMIN_TOKEN, zip))
448            .await
449            .unwrap();
450
451        assert_eq!(r.status(), StatusCode::OK);
452        let version = Version::try_from("1.0.0").unwrap();
453        let key =
454            DocsStorage::file_key(&docs_name("my-crate"), &version, "doc/My_Crate/index.html");
455        assert!(docs_storage.exists(&key).await.unwrap());
456        let other = DocsStorage::version_prefix(&docs_name("my_crate"), &version);
457        assert!(docs_storage.list(&other).await.unwrap().is_empty());
458        assert_eq!(
459            docs_link(&db, "my-crate").await.as_deref(),
460            Some("/docs/my-crate/1.0.0/doc/My_Crate/index.html")
461        );
462    }
463
464    fn docs_name(name: &str) -> DocsName {
465        DocsName::from(&OriginalName::try_from(name).unwrap())
466    }
467
468    #[tokio::test]
469    async fn publish_docs_uploads_files_and_updates_docs_link() {
470        let (state, db) = sqlite_test_state().await;
471        add_crate(&db, "test-crate", "1.0.0").await;
472        let tmp = tempfile::tempdir().unwrap();
473        let docs_storage = fs_docs_storage(&tmp);
474        let zip = build_zip(&[("doc/test_crate/index.html", b"<html>docs</html>")]);
475
476        let r = app(state, docs_storage.clone())
477            .oneshot(publish_request("/test-crate/1.0.0", ADMIN_TOKEN, zip))
478            .await
479            .unwrap();
480
481        assert_eq!(r.status(), StatusCode::OK);
482        let key = DocsStorage::file_key(
483            &docs_name("test-crate"),
484            &Version::try_from("1.0.0").unwrap(),
485            "doc/test_crate/index.html",
486        );
487        let stored = docs_storage.get(&key).await.unwrap();
488        assert_eq!(stored, bytes::Bytes::from_static(b"<html>docs</html>"));
489        assert_eq!(
490            docs_link(&db, "test-crate").await.as_deref(),
491            Some("/docs/test-crate/1.0.0/doc/test_crate/index.html")
492        );
493    }
494
495    #[tokio::test]
496    async fn publish_docs_rejects_read_only_owner() {
497        let (state, db) = sqlite_test_state().await;
498        let token = token_user(&db, "reader", UserFlags::read_only()).await;
499        TestCrateBuilder::new(&db)
500            .name("test-crate")
501            .owner("reader")
502            .build()
503            .await
504            .unwrap();
505        let tmp = tempfile::tempdir().unwrap();
506        let docs_storage = fs_docs_storage(&tmp);
507        let zip = build_zip(&[("doc/test_crate/index.html", b"<html>docs</html>")]);
508
509        let r = app(state, docs_storage.clone())
510            .oneshot(publish_request("/test-crate/1.0.0", &token, zip))
511            .await
512            .unwrap();
513
514        assert_eq!(r.status(), StatusCode::FORBIDDEN);
515        let key = DocsStorage::file_key(
516            &docs_name("test-crate"),
517            &Version::try_from("1.0.0").unwrap(),
518            "doc/test_crate/index.html",
519        );
520        assert!(!docs_storage.exists(&key).await.unwrap());
521        assert_eq!(docs_link(&db, "test-crate").await, None);
522    }
523
524    // A non-owner gets the same response for a missing crate or version as for
525    // an existing crate, so that the existence of a crate is not revealed.
526    #[tokio::test]
527    async fn publish_docs_non_owner_cannot_tell_missing_from_existing_crate() {
528        let (state, db) = sqlite_test_state().await;
529        add_crate(&db, "test-crate", "1.0.0").await;
530        let token = token_user(&db, "other", UserFlags::normal()).await;
531        let tmp = tempfile::tempdir().unwrap();
532        let docs_storage = fs_docs_storage(&tmp);
533
534        for uri in ["/test-crate/1.0.0", "/test-crate/9.9.9", "/missing/1.0.0"] {
535            let zip = build_zip(&[("doc/x/index.html", b"<html>docs</html>")]);
536            let r = app(state.clone(), docs_storage.clone())
537                .oneshot(publish_request(uri, &token, zip))
538                .await
539                .unwrap();
540            assert_eq!(r.status(), StatusCode::FORBIDDEN, "{uri}");
541        }
542        assert_eq!(docs_link(&db, "test-crate").await, None);
543    }
544
545    #[tokio::test]
546    async fn publish_docs_propagates_extraction_failure() {
547        // A well-formed zip whose only entry escapes the extraction directory. The
548        // archive parses fine (`ZipArchive::new` succeeds) but `ZipArchive::extract`
549        // rejects the unsafe path, so the inner `Result` from `docs.extract(..)` must
550        // reach the caller as an error (previously silently discarded, returning 200).
551        let (state, db) = sqlite_test_state().await;
552        add_crate(&db, "test-crate", "1.0.0").await;
553        let tmp = tempfile::tempdir().unwrap();
554        let zip = build_zip(&[("../escape.html", b"evil")]);
555
556        let r = app(state, fs_docs_storage(&tmp))
557            .oneshot(publish_request("/test-crate/1.0.0", ADMIN_TOKEN, zip))
558            .await
559            .unwrap();
560
561        // Previously this error was silently discarded (`let _ = ...`), returning 200
562        // even though extraction failed. It must now surface as a client error.
563        assert_eq!(r.status(), StatusCode::BAD_REQUEST);
564        assert_eq!(docs_link(&db, "test-crate").await, None);
565    }
566
567    async fn docs_storage_with_latest_docs() -> (tempfile::TempDir, Arc<DocsStorage>) {
568        let tmp = tempfile::tempdir().unwrap();
569        let docs_storage = fs_docs_storage(&tmp);
570        let key = DocsStorage::file_key(
571            &docs_name("secret"),
572            &Version::try_from("1.0.0").unwrap(),
573            "doc/secret/index.html",
574        );
575        docs_storage
576            .put(&key, bytes::Bytes::from_static(b"<html>docs</html>"))
577            .await
578            .unwrap();
579        (tmp, docs_storage)
580    }
581
582    /// App state with the crate `secret`, which is download-restricted if `restricted`.
583    async fn state_with_crate(restricted: bool) -> (AppStateData, TestDb) {
584        let (state, db) = sqlite_test_state().await;
585        add_restricted_crate(&db, "secret", restricted).await;
586        (state, db)
587    }
588
589    async fn latest_location(
590        state: AppStateData,
591        docs_storage: Arc<DocsStorage>,
592        token: Option<&str>,
593    ) -> String {
594        let mut request = Request::get("/secret/latest");
595        if let Some(token) = token {
596            request = request.header(header::AUTHORIZATION, token);
597        }
598        let r = app(state, docs_storage)
599            .oneshot(request.body(Body::empty()).unwrap())
600            .await
601            .unwrap();
602        assert_eq!(r.status(), StatusCode::TEMPORARY_REDIRECT);
603        r.headers()[header::LOCATION].to_str().unwrap().to_string()
604    }
605
606    #[tokio::test]
607    async fn latest_docs_redirects_to_docs_for_unrestricted_crate() {
608        let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
609        let (state, _db) = state_with_crate(false).await;
610
611        let location = latest_location(state, docs_storage, None).await;
612
613        assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
614    }
615
616    #[tokio::test]
617    async fn latest_docs_redirects_to_docs_of_crate_with_uppercase_letters() {
618        let docs_storage = memory_docs_storage();
619        let key = DocsStorage::file_key(
620            &docs_name("my-crate"),
621            &Version::try_from("1.0.0").unwrap(),
622            "doc/My_Crate/index.html",
623        );
624        docs_storage
625            .put(&key, bytes::Bytes::from_static(b"<html>docs</html>"))
626            .await
627            .unwrap();
628        let (state, db) = sqlite_test_state().await;
629        add_crate(&db, "My-Crate", "1.0.0").await;
630
631        let r = app(state, docs_storage)
632            .oneshot(
633                Request::get("/My-Crate/latest")
634                    .body(Body::empty())
635                    .unwrap(),
636            )
637            .await
638            .unwrap();
639
640        assert_eq!(
641            r.headers()[header::LOCATION],
642            "/docs/my-crate/1.0.0/doc/My_Crate/index.html"
643        );
644    }
645
646    #[tokio::test]
647    async fn latest_docs_hides_restricted_crate_from_anonymous_user() {
648        let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
649        let (state, _db) = state_with_crate(true).await;
650
651        let location = latest_location(state, docs_storage, None).await;
652
653        assert_eq!(location, "/");
654    }
655
656    #[tokio::test]
657    async fn latest_docs_hides_restricted_crate_from_user_without_access() {
658        let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
659        let (state, db) = state_with_crate(true).await;
660        let token = token_user(&db, "bob", UserFlags::normal()).await;
661
662        let location = latest_location(state, docs_storage, Some(&token)).await;
663
664        assert_eq!(location, "/");
665    }
666
667    #[tokio::test]
668    async fn latest_docs_redirects_crate_user_of_restricted_crate() {
669        let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
670        let (state, db) = state_with_crate(true).await;
671        let token = token_user(&db, "alice", UserFlags::normal()).await;
672        db.add_crate_user(&canonical("secret"), &user_name("alice"))
673            .await
674            .unwrap();
675
676        let location = latest_location(state, 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_redirects_owner_of_restricted_crate() {
683        let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
684        let (state, db) = state_with_crate(true).await;
685        let token = token_user(&db, "olivia", UserFlags::normal()).await;
686        db.add_owner(&canonical("secret"), &user_name("olivia"))
687            .await
688            .unwrap();
689
690        let location = latest_location(state, docs_storage, Some(&token)).await;
691
692        assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
693    }
694
695    #[tokio::test]
696    async fn latest_docs_redirects_group_member_of_restricted_crate() {
697        let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
698        let (state, db) = state_with_crate(true).await;
699        let token = token_user(&db, "greg", UserFlags::normal()).await;
700        let group = GroupName::new("readers").unwrap();
701        db.add_group(&group).await.unwrap();
702        db.add_group_user(&group, &user_name("greg")).await.unwrap();
703        db.add_crate_group(&canonical("secret"), &group)
704            .await
705            .unwrap();
706
707        let location = latest_location(state, docs_storage, Some(&token)).await;
708
709        assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
710    }
711
712    #[tokio::test]
713    async fn latest_docs_hides_crate_when_restriction_check_fails() {
714        let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
715        let (state, db) = state_with_crate(false).await;
716        test_drop_table(&db, "krate").await.unwrap();
717
718        let location = latest_location(state, docs_storage, None).await;
719
720        assert_eq!(location, "/");
721    }
722
723    #[tokio::test]
724    async fn latest_docs_hides_restricted_crate_when_access_check_fails() {
725        let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
726        let (state, db) = state_with_crate(true).await;
727        // Owners may read the docs, but the crate users are checked first.
728        let token = token_user(&db, "olivia", UserFlags::normal()).await;
729        db.add_owner(&canonical("secret"), &user_name("olivia"))
730            .await
731            .unwrap();
732        test_drop_table(&db, "crate_user").await.unwrap();
733
734        let location = latest_location(state, docs_storage, Some(&token)).await;
735
736        assert_eq!(location, "/");
737    }
738}