Skip to main content

pipedash_web/
lib.rs

1pub mod demo;
2mod download_tickets;
3pub mod error;
4pub mod pairing;
5mod pairing_codes;
6pub mod push;
7mod routes;
8pub mod state;
9#[cfg(feature = "embedded-frontend")]
10mod static_files;
11#[cfg(test)]
12mod test_support;
13pub mod ws;
14
15use std::future::Future;
16use std::sync::Arc;
17
18use axum::{
19    extract::{
20        Request,
21        State,
22    },
23    http::{
24        header::{
25            AUTHORIZATION,
26            HOST,
27            ORIGIN,
28            UPGRADE,
29        },
30        Method,
31        StatusCode,
32    },
33    middleware::Next,
34    response::{
35        IntoResponse,
36        Response,
37    },
38    routing::get,
39    Router,
40};
41use subtle::ConstantTimeEq;
42
43use crate::error::{
44    ApiError,
45    AppError,
46};
47use crate::state::{
48    AppState,
49    CurrentDevice,
50};
51
52pub fn env_flag(value: &str) -> bool {
53    value == "true" || value == "1"
54}
55
56fn token_matches(provided: &str, expected: &str) -> bool {
57    provided.as_bytes().ct_eq(expected.as_bytes()).into()
58}
59
60fn bearer_token(auth_header: Option<&str>) -> Option<&str> {
61    auth_header.and_then(|h| h.strip_prefix("Bearer "))
62}
63
64fn bearer_matches(auth_header: Option<&str>, token: &str) -> bool {
65    bearer_token(auth_header).is_some_and(|t| token_matches(t, token))
66}
67
68fn is_websocket_upgrade(req: &Request) -> bool {
69    req.headers()
70        .get(UPGRADE)
71        .is_some_and(|value| value.as_bytes().eq_ignore_ascii_case(b"websocket"))
72}
73
74fn is_cross_origin_request(req: &Request) -> bool {
75    let guarded = !matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS)
76        || is_websocket_upgrade(req);
77    if !guarded {
78        return false;
79    }
80
81    let headers = req.headers();
82    if let Some(site) = headers.get("sec-fetch-site") {
83        return !matches!(site.as_bytes(), b"same-origin" | b"none");
84    }
85
86    let Some(origin) = headers.get(ORIGIN) else {
87        return false;
88    };
89    let Some((origin_scheme, origin_host)) = origin.to_str().ok().and_then(|o| o.split_once("://"))
90    else {
91        return true;
92    };
93    let Some(host) = headers
94        .get(HOST)
95        .and_then(|h| h.to_str().ok())
96        .or_else(|| req.uri().authority().map(|a| a.as_str()))
97    else {
98        return true;
99    };
100    let scheme = headers
101        .get("x-forwarded-proto")
102        .and_then(|p| p.to_str().ok())
103        .and_then(|p| p.split(',').next())
104        .map_or("http", str::trim);
105    let default_port = if scheme.eq_ignore_ascii_case("https") {
106        ":443"
107    } else {
108        ":80"
109    };
110    let host = host.strip_suffix(default_port).unwrap_or(host);
111
112    !(origin_scheme.eq_ignore_ascii_case(scheme) && origin_host.eq_ignore_ascii_case(host))
113}
114
115pub async fn reject_cross_origin_requests(
116    req: Request, next: Next,
117) -> Result<Response, StatusCode> {
118    if is_cross_origin_request(&req) {
119        tracing::warn!(
120            method = %req.method(),
121            path = req.uri().path(),
122            origin = ?req.headers().get(ORIGIN),
123            "Rejected cross-origin request. Set PIPEDASH_CORS_ALLOW_ALL=true to allow other origins"
124        );
125        return Err(StatusCode::FORBIDDEN);
126    }
127
128    Ok(next.run(req).await)
129}
130
131async fn auth_middleware(
132    State(state): State<AppState>, mut req: Request, next: Next,
133) -> Result<Response, StatusCode> {
134    let path = req.uri().path();
135
136    if !path.starts_with("/api/v1/")
137        || path.starts_with("/api/v1/health")
138        || path.starts_with("/api/v1/setup")
139        || path == "/api/v1/vault/status"
140        || path == "/api/v1/ws"
141        || path == "/api/v1/plugins"
142        || (req.method() == Method::GET && path.starts_with("/api/v1/downloads/"))
143    {
144        return Ok(next.run(req).await);
145    }
146
147    if state.desktop_host.is_some() && state.inner.read().await.core.is_none() {
148        return Ok(host_locked());
149    }
150
151    let auth_header = req
152        .headers()
153        .get(AUTHORIZATION)
154        .and_then(|v| v.to_str().ok());
155
156    if state
157        .api_token()
158        .is_some_and(|token| bearer_matches(auth_header, &token))
159    {
160        return Ok(next.run(req).await);
161    }
162
163    if let Some(token) = bearer_token(auth_header) {
164        match state.paired_device(token).await {
165            Ok(Some(device)) => {
166                req.extensions_mut().insert(CurrentDevice(device.id));
167                return Ok(next.run(req).await);
168            }
169            Ok(None) => {}
170            Err(_) => return Ok(server_not_ready()),
171        }
172    }
173
174    if state.requires_auth() {
175        Err(StatusCode::UNAUTHORIZED)
176    } else {
177        Ok(next.run(req).await)
178    }
179}
180
181fn is_host_managed(path: &str) -> bool {
182    [
183        "/api/v1/setup",
184        "/api/v1/storage",
185        "/api/v1/vault",
186        "/api/v1/push",
187    ]
188    .iter()
189    .any(|prefix| path.starts_with(prefix))
190        || path == "/api/v1/factory-reset"
191}
192
193fn host_error(status: StatusCode, code: &str, message: &str) -> Response {
194    AppError::new(status, ApiError::new(code, message)).into_response()
195}
196
197const HOST_LOCKED_MESSAGE: &str =
198    "Pipedash is locked on the computer that shares it. Unlock it there.";
199const SERVER_NOT_READY_MESSAGE: &str = "Pipedash isn't ready yet.";
200
201fn host_locked() -> Response {
202    host_error(
203        StatusCode::SERVICE_UNAVAILABLE,
204        "HOST_LOCKED",
205        HOST_LOCKED_MESSAGE,
206    )
207}
208
209fn server_not_ready() -> Response {
210    host_error(
211        StatusCode::SERVICE_UNAVAILABLE,
212        "SERVER_NOT_READY",
213        SERVER_NOT_READY_MESSAGE,
214    )
215}
216
217async fn hosted_guard(State(state): State<AppState>, req: Request, next: Next) -> Response {
218    let device = req.extensions().get::<CurrentDevice>().is_some();
219    if state.desktop_host.is_none() && !device {
220        return next.run(req).await;
221    }
222
223    let path = req.uri().path();
224    let always_open = path.starts_with("/api/v1/health")
225        || path == "/api/v1/vault/status"
226        || path == "/api/v1/setup/status"
227        || path == "/api/v1/ws";
228    if !always_open {
229        if is_host_managed(path) {
230            let message = if state.desktop_host.is_some() {
231                "Change this on the computer that shares Pipedash."
232            } else {
233                "Change this on the server that runs Pipedash."
234            };
235            return host_error(StatusCode::CONFLICT, "HOST_MANAGED", message);
236        }
237        if state.desktop_host.is_some() && state.inner.read().await.core.is_none() {
238            return host_locked();
239        }
240    }
241
242    next.run(req).await
243}
244
245fn routes() -> Router<AppState> {
246    Router::new()
247        .nest("/api/v1", routes::api_router())
248        .route("/api/v1/ws", get(ws::ws_handler))
249}
250
251fn guarded(router: Router<AppState>, state: AppState) -> Router {
252    let router = if demo::enabled() {
253        router.layer(axum::middleware::from_fn(demo::reject_changes))
254    } else {
255        router
256    };
257
258    router
259        .layer(axum::middleware::from_fn_with_state(
260            state.clone(),
261            hosted_guard,
262        ))
263        .layer(axum::middleware::from_fn_with_state(
264            state.clone(),
265            auth_middleware,
266        ))
267        .with_state(state)
268}
269
270#[cfg(feature = "embedded-frontend")]
271fn with_frontend(router: Router<AppState>, base_path: Option<String>) -> Router<AppState> {
272    match base_path {
273        Some(base_path) => router.fallback(get(move |uri| {
274            let base_path = base_path.clone();
275            async move { static_files::serve_static(uri, &base_path) }
276        })),
277        None => router.fallback(get(|| async { StatusCode::NOT_FOUND })),
278    }
279}
280
281#[cfg(not(feature = "embedded-frontend"))]
282fn with_frontend(router: Router<AppState>, _base_path: Option<String>) -> Router<AppState> {
283    router.fallback(get(|| async { StatusCode::NOT_FOUND }))
284}
285
286pub fn app(state: AppState, frontend_base_path: Option<String>) -> Router {
287    guarded(with_frontend(routes(), frontend_base_path), state)
288}
289
290pub fn host_router(state: AppState) -> Router {
291    guarded(
292        routes().fallback(get(|| async { StatusCode::NOT_FOUND })),
293        state,
294    )
295}
296
297pub async fn serve_host(
298    listener: tokio::net::TcpListener, state: AppState,
299    shutdown: impl Future<Output = ()> + Send + 'static,
300) -> std::io::Result<()> {
301    let closing = Arc::clone(&state.closing);
302    let router = host_router(state).layer(axum::middleware::from_fn(reject_cross_origin_requests));
303    axum::serve(listener, router)
304        .with_graceful_shutdown(async move {
305            shutdown.await;
306            closing.send_replace(true);
307        })
308        .await
309}
310
311#[cfg(test)]
312mod tests {
313    use std::sync::Arc;
314
315    use axum::body::Body;
316    use axum::http::header;
317    use pipedash_core::infrastructure::{
318        PipedashConfig,
319        StorageBackendType,
320        StorageManager,
321    };
322    use pipedash_core::ProviderConfig;
323    use tower::ServiceExt;
324
325    use super::*;
326    use crate::ws::WebSocketEventBus;
327
328    const TOKEN: &str = "operator-secret";
329
330    fn app_with_token() -> Router {
331        let state = AppState::config_error(Arc::new(WebSocketEventBus::new()), "test".into());
332        state.set_api_token(TOKEN);
333        app(state, None)
334    }
335
336    async fn send(app: &Router, method: Method, uri: &str, auth: Option<&str>) -> StatusCode {
337        let mut request = axum::http::Request::builder()
338            .method(method)
339            .uri(uri)
340            .header(header::CONTENT_TYPE, "application/json");
341        if let Some(token) = auth {
342            request = request.header(AUTHORIZATION, format!("Bearer {token}"));
343        }
344        let body = if uri.ends_with("/unlock") {
345            Body::from(r#"{"password":"wrong-password"}"#)
346        } else {
347            Body::empty()
348        };
349
350        app.clone()
351            .oneshot(request.body(body).unwrap())
352            .await
353            .unwrap()
354            .status()
355    }
356
357    async fn assert_api_still_requires_token(app: &Router) {
358        assert_eq!(
359            send(app, Method::GET, "/api/v1/providers", None).await,
360            StatusCode::UNAUTHORIZED
361        );
362        assert_ne!(
363            send(app, Method::GET, "/api/v1/providers", Some(TOKEN)).await,
364            StatusCode::UNAUTHORIZED
365        );
366    }
367
368    #[tokio::test]
369    async fn vault_status_is_public_but_lock_and_unlock_require_token() {
370        let app = app_with_token();
371
372        assert_eq!(
373            send(&app, Method::GET, "/api/v1/vault/status", None).await,
374            StatusCode::OK
375        );
376        for uri in ["/api/v1/vault/lock", "/api/v1/vault/unlock"] {
377            assert_eq!(
378                send(&app, Method::POST, uri, None).await,
379                StatusCode::UNAUTHORIZED,
380                "{uri}"
381            );
382            assert_eq!(
383                send(&app, Method::POST, uri, Some("wrong")).await,
384                StatusCode::UNAUTHORIZED,
385                "{uri}"
386            );
387        }
388    }
389
390    #[tokio::test]
391    async fn paired_device_token_is_accepted_and_unknown_token_is_rejected() {
392        let host = test_support::hosted("lib-paired-token").await;
393        let (_, token) = host
394            .state
395            .core()
396            .await
397            .unwrap()
398            .pair_device("Pixel", "android")
399            .await
400            .unwrap();
401        let app = app(host.state.clone(), None);
402
403        assert_eq!(
404            send(&app, Method::GET, "/api/v1/devices", Some(&token)).await,
405            StatusCode::OK
406        );
407        for auth in [
408            None,
409            Some("unknown-token"),
410            Some(token.to_uppercase().as_str()),
411        ] {
412            assert_eq!(
413                send(&app, Method::GET, "/api/v1/devices", auth).await,
414                StatusCode::UNAUTHORIZED,
415                "{auth:?}"
416            );
417        }
418    }
419
420    async fn get_json(
421        app: &Router, uri: &str, auth: Option<&str>,
422    ) -> (StatusCode, serde_json::Value) {
423        let mut request = axum::http::Request::builder().uri(uri);
424        if let Some(token) = auth {
425            request = request.header(AUTHORIZATION, format!("Bearer {token}"));
426        }
427        let response = app
428            .clone()
429            .oneshot(request.body(Body::empty()).unwrap())
430            .await
431            .unwrap();
432        let status = response.status();
433        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
434            .await
435            .unwrap();
436
437        (status, serde_json::from_slice(&body).unwrap_or_default())
438    }
439
440    #[tokio::test]
441    async fn a_device_token_is_resolved_on_a_server_without_an_api_token() {
442        let host = test_support::server("lib-open-device").await;
443        assert!(!host.state.requires_auth());
444        let (device, token) = host
445            .state
446            .core()
447            .await
448            .unwrap()
449            .pair_device("Pixel", "android")
450            .await
451            .unwrap();
452        let app = app(host.state.clone(), None);
453
454        let (status, body) = get_json(&app, "/api/v1/devices", Some(&token)).await;
455        assert_eq!(status, StatusCode::OK, "{body}");
456        assert_eq!(body["devices"][0]["id"], device.id);
457        assert_eq!(body["devices"][0]["current"], true);
458
459        let (status, body) = get_json(&app, "/api/v1/storage/config", Some(&token)).await;
460        assert_eq!(status, StatusCode::CONFLICT);
461        assert_eq!(body["code"], "HOST_MANAGED");
462
463        for auth in [None, Some("unknown-token")] {
464            let (status, body) = get_json(&app, "/api/v1/devices", auth).await;
465            assert_eq!(status, StatusCode::OK, "{auth:?}");
466            assert_eq!(body["devices"][0]["current"], false, "{auth:?}");
467            assert_ne!(
468                send(&app, Method::GET, "/api/v1/storage/config", auth).await,
469                StatusCode::CONFLICT,
470                "{auth:?}"
471            );
472        }
473    }
474
475    #[tokio::test]
476    async fn a_failed_device_lookup_is_a_retryable_503_not_a_401() {
477        let host = test_support::hosted("lib-lookup-failure").await;
478        let core = host.state.core().await.unwrap();
479        let (_, token) = core.pair_device("Pixel", "android").await.unwrap();
480        let app = app(host.state.clone(), None);
481        let pipedash_core::infrastructure::database::DatabasePool::Sqlite(pool) =
482            core.provider_service.repository().cache_pool().clone()
483        else {
484            panic!("expected a SQLite pool");
485        };
486        pool.close().await;
487
488        let (status, body) = get_json(&app, "/api/v1/providers", Some(&token)).await;
489
490        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
491        assert_eq!(body["code"], "SERVER_NOT_READY");
492    }
493
494    #[tokio::test]
495    async fn a_hosted_state_without_an_api_token_still_requires_a_token() {
496        let host = test_support::hosted("lib-hosted-auth").await;
497        assert!(host.state.api_token().is_none());
498        let app = app(host.state.clone(), None);
499
500        assert_eq!(
501            send(&app, Method::GET, "/api/v1/providers", None).await,
502            StatusCode::UNAUTHORIZED
503        );
504        assert_eq!(
505            send(&app, Method::GET, "/api/v1/vault/status", None).await,
506            StatusCode::OK
507        );
508    }
509
510    #[tokio::test]
511    async fn a_paired_device_cannot_manage_a_server_host() {
512        let host = test_support::hosted("lib-server-guard").await;
513        let (_, token) = host
514            .state
515            .core()
516            .await
517            .unwrap()
518            .pair_device("Pixel", "android")
519            .await
520            .unwrap();
521        let mut state = host.state.clone();
522        state.desktop_host = None;
523        state.set_api_token(TOKEN);
524        let app = app(state, None);
525
526        assert_eq!(
527            send(&app, Method::GET, "/api/v1/providers", Some(&token)).await,
528            StatusCode::OK
529        );
530        for (method, uri) in [
531            (Method::POST, "/api/v1/factory-reset"),
532            (Method::POST, "/api/v1/vault/lock"),
533            (Method::GET, "/api/v1/storage/config"),
534        ] {
535            assert_eq!(
536                send(&app, method, uri, Some(&token)).await,
537                StatusCode::CONFLICT,
538                "{uri}"
539            );
540        }
541        assert_ne!(
542            send(&app, Method::GET, "/api/v1/storage/config", Some(TOKEN)).await,
543            StatusCode::CONFLICT
544        );
545    }
546
547    #[tokio::test]
548    async fn a_hosted_state_refuses_host_managed_routes_and_reports_a_locked_core() {
549        let host = test_support::hosted("lib-hosted-guard").await;
550        let (_, token) = host
551            .state
552            .core()
553            .await
554            .unwrap()
555            .pair_device("Pixel", "android")
556            .await
557            .unwrap();
558        let app = app(host.state.clone(), None);
559
560        for (method, uri) in [
561            (Method::POST, "/api/v1/factory-reset"),
562            (Method::POST, "/api/v1/vault/lock"),
563            (Method::GET, "/api/v1/storage/config"),
564            (Method::GET, "/api/v1/push/subscriptions"),
565        ] {
566            assert_eq!(
567                send(&app, method, uri, Some(&token)).await,
568                StatusCode::CONFLICT,
569                "{uri}"
570            );
571        }
572        assert_ne!(
573            send(&app, Method::GET, "/api/v1/providers", Some(&token)).await,
574            StatusCode::CONFLICT
575        );
576
577        let core = host.state.core().await.unwrap();
578        assert_eq!(
579            send(&app, Method::GET, "/api/v1/downloads/abc", None).await,
580            StatusCode::NOT_FOUND
581        );
582
583        host.state.set_core(None).await;
584        assert_eq!(
585            send(&app, Method::GET, "/api/v1/providers", Some(&token)).await,
586            StatusCode::SERVICE_UNAVAILABLE
587        );
588        assert_eq!(
589            send(&app, Method::GET, "/api/v1/downloads/abc", None).await,
590            StatusCode::SERVICE_UNAVAILABLE
591        );
592        assert_eq!(
593            send(&app, Method::GET, "/api/v1/vault/status", None).await,
594            StatusCode::OK
595        );
596
597        host.state.set_core(Some(core)).await;
598        assert_eq!(
599            send(&app, Method::GET, "/api/v1/downloads/abc", None).await,
600            StatusCode::NOT_FOUND
601        );
602    }
603
604    #[tokio::test]
605    async fn vault_lock_keeps_api_auth() {
606        let app = app_with_token();
607
608        send(&app, Method::POST, "/api/v1/vault/lock", None).await;
609        assert_api_still_requires_token(&app).await;
610
611        assert_eq!(
612            send(&app, Method::POST, "/api/v1/vault/lock", Some(TOKEN)).await,
613            StatusCode::OK
614        );
615        assert_api_still_requires_token(&app).await;
616    }
617
618    async fn locked_sqlite_vault(data_dir: &std::path::Path) -> StorageManager {
619        let mut config = PipedashConfig::default();
620        config.storage.backend = StorageBackendType::Sqlite;
621        config.storage.data_dir = data_dir.display().to_string();
622
623        let mut seeded = config.clone();
624        seeded.storage.vault_password = Some("vault-password".into());
625        let manager = StorageManager::from_config(seeded, false).await.unwrap();
626        let provider = ProviderConfig {
627            id: None,
628            name: "github".into(),
629            provider_type: "github".into(),
630            token: String::new(),
631            config: Default::default(),
632            refresh_interval: 30,
633            version: None,
634        };
635        let id = manager
636            .config_backend()
637            .create_provider(&provider)
638            .await
639            .unwrap();
640        manager
641            .token_store()
642            .await
643            .store_token(id, "ghp_secret")
644            .await
645            .unwrap();
646
647        let locked = StorageManager::from_config_allow_locked(config, false)
648            .await
649            .unwrap();
650        assert!(locked.is_vault_locked().await);
651        locked
652    }
653
654    #[tokio::test]
655    async fn failed_vault_unlock_keeps_api_auth() {
656        let data_dir =
657            std::env::temp_dir().join(format!("pipedash-failed-unlock-{}", std::process::id()));
658        let state = AppState::config_error(Arc::new(WebSocketEventBus::new()), "test".into());
659        state.inner.write().await.storage_manager = Some(locked_sqlite_vault(&data_dir).await);
660        state.set_api_token(TOKEN);
661        let app = app(state.clone(), None);
662
663        let response = app
664            .clone()
665            .oneshot(
666                axum::http::Request::post("/api/v1/vault/unlock")
667                    .header(header::CONTENT_TYPE, "application/json")
668                    .header(AUTHORIZATION, format!("Bearer {TOKEN}"))
669                    .body(Body::from(r#"{"password":"wrong-password"}"#))
670                    .unwrap(),
671            )
672            .await
673            .unwrap();
674        let status = response.status();
675        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
676            .await
677            .unwrap();
678        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
679
680        assert_eq!(status, StatusCode::OK);
681        assert_eq!(body["success"], false);
682        assert_eq!(state.api_token().as_deref(), Some(TOKEN));
683        assert_api_still_requires_token(&app).await;
684        let _ = std::fs::remove_dir_all(&data_dir);
685    }
686
687    #[tokio::test]
688    async fn empty_vault_unlock_cannot_replace_the_api_token() {
689        let data_dir =
690            std::env::temp_dir().join(format!("pipedash-empty-vault-{}", std::process::id()));
691        let mut config = PipedashConfig::default();
692        config.storage.backend = StorageBackendType::Sqlite;
693        config.storage.data_dir = data_dir.display().to_string();
694        config.storage.vault_password = Some(TOKEN.into());
695        let state = AppState::config_error(Arc::new(WebSocketEventBus::new()), "test".into());
696        state.inner.write().await.storage_manager =
697            Some(StorageManager::from_config(config, false).await.unwrap());
698        state.set_api_token(TOKEN);
699        let app = app(state.clone(), None);
700
701        for uri in ["/api/v1/vault/lock", "/api/v1/vault/unlock"] {
702            assert_eq!(
703                send(&app, Method::POST, uri, None).await,
704                StatusCode::UNAUTHORIZED,
705                "{uri}"
706            );
707        }
708        send(&app, Method::POST, "/api/v1/vault/unlock", Some(TOKEN)).await;
709
710        assert_eq!(state.api_token().as_deref(), Some(TOKEN));
711        assert_eq!(
712            send(
713                &app,
714                Method::GET,
715                "/api/v1/providers",
716                Some("wrong-password")
717            )
718            .await,
719            StatusCode::UNAUTHORIZED
720        );
721        assert_api_still_requires_token(&app).await;
722        let _ = std::fs::remove_dir_all(&data_dir);
723    }
724
725    #[test]
726    fn bearer_matches_accepts_exact_token() {
727        assert!(bearer_matches(
728            Some("Bearer operator-secret"),
729            "operator-secret"
730        ));
731    }
732
733    #[test]
734    fn bearer_matches_rejects_missing_wrong_and_partial_tokens() {
735        for auth_header in [
736            None,
737            Some("operator-secret"),
738            Some("Bearer "),
739            Some("Bearer wrong"),
740            Some("Bearer operator"),
741            Some("Bearer operator-secret-extra"),
742        ] {
743            assert!(
744                !bearer_matches(auth_header, "operator-secret"),
745                "accepted {auth_header:?}"
746            );
747        }
748    }
749
750    #[tokio::test]
751    async fn only_get_downloads_skip_the_token() {
752        let app = app_with_token();
753
754        assert_eq!(
755            send(
756                &app,
757                Method::POST,
758                "/api/v1/pipelines/p/runs/1/logs/n/download-ticket",
759                None
760            )
761            .await,
762            StatusCode::UNAUTHORIZED
763        );
764        assert_eq!(
765            send(&app, Method::GET, "/api/v1/downloads/abc", None).await,
766            StatusCode::NOT_FOUND
767        );
768        for method in [Method::POST, Method::DELETE] {
769            assert_eq!(
770                send(&app, method, "/api/v1/downloads/abc", None).await,
771                StatusCode::UNAUTHORIZED
772            );
773        }
774        assert_eq!(
775            send(&app, Method::GET, "/api/v1/pipelines/p/runs/1/logs", None).await,
776            StatusCode::UNAUTHORIZED
777        );
778    }
779}