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}