pipedash-web 0.8.0

REST API server for Pipedash
use axum::{
    extract::Request,
    http::{
        Method,
        StatusCode,
    },
    middleware::Next,
    response::{
        IntoResponse,
        Response,
    },
};

use crate::error::{
    ApiError,
    AppError,
};

pub fn enabled() -> bool {
    std::env::var("PIPEDASH_DEMO_MODE").is_ok_and(|value| crate::env_flag(&value))
}

pub fn is_blocked(method: &Method, path: &str) -> bool {
    let Some(route) = path.strip_prefix("/api/v1/") else {
        return false;
    };

    if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) {
        return route.starts_with("storage/")
            || route == "providers/export"
            || route.starts_with("push/");
    }

    !(*method == Method::POST && allows_post(route))
}

fn allows_post(route: &str) -> bool {
    route == "vault/unlock"
        || route == "refresh/all"
        || route == "metrics/aggregated"
        || (route.starts_with("providers/") && route.ends_with("/refresh"))
        || (route.starts_with("metrics/pipelines/") && route.ends_with("/query"))
        || (route.starts_with("pipelines/")
            && (route.ends_with("/search") || route.ends_with("/download-ticket")))
}

pub async fn reject_changes(req: Request, next: Next) -> Response {
    if is_blocked(req.method(), req.uri().path()) {
        return AppError::new(
            StatusCode::FORBIDDEN,
            ApiError::new(
                "DEMO_READ_ONLY",
                "This is a read-only demo. Install Pipedash to connect your own providers.",
            ),
        )
        .into_response();
    }

    next.run(req).await
}

#[cfg(test)]
mod tests {
    use axum::{
        body::Body,
        routing::post,
        Router,
    };
    use tower::ServiceExt;

    use super::*;

    #[test]
    fn blocks_configuration_and_pipeline_actions() {
        let pipeline = "/api/v1/pipelines/github__1__acme__web__ci";
        let blocked = [
            (Method::POST, "/api/v1/factory-reset".to_string()),
            (Method::POST, "/api/v1/providers".to_string()),
            (Method::PUT, "/api/v1/providers/1".to_string()),
            (Method::DELETE, "/api/v1/providers/1".to_string()),
            (
                Method::PUT,
                "/api/v1/providers/1/refresh-interval".to_string(),
            ),
            (Method::POST, format!("{pipeline}/trigger")),
            (Method::POST, format!("{pipeline}/runs/7/cancel")),
            (Method::POST, format!("{pipeline}/runs/7/rerun")),
            (Method::PUT, "/api/v1/settings/general".to_string()),
            (
                Method::PUT,
                "/api/v1/preferences/table/1/pipeline_runs".to_string(),
            ),
            (Method::POST, "/api/v1/vault/lock".to_string()),
            (Method::POST, "/api/v1/setup/config".to_string()),
            (Method::DELETE, "/api/v1/cache".to_string()),
            (Method::GET, "/api/v1/storage/config/content".to_string()),
            (Method::GET, "/api/v1/providers/export".to_string()),
            (Method::GET, "/api/v1/push/subscriptions".to_string()),
        ];

        for (method, path) in blocked {
            assert!(
                is_blocked(&method, &path),
                "{method} {path} should be blocked"
            );
        }
    }

    #[test]
    fn allows_reads_and_queries() {
        let allowed = [
            (Method::GET, "/api/v1/pipelines/cached"),
            (Method::GET, "/api/v1/providers/1"),
            (Method::GET, "/api/v1/ws"),
            (Method::GET, "/"),
            (Method::GET, "/assets/index.js"),
            (Method::OPTIONS, "/api/v1/providers"),
            (Method::POST, "/api/v1/pipelines/p/runs/7/logs/job/search"),
            (
                Method::POST,
                "/api/v1/pipelines/p/runs/7/logs/job/download-ticket",
            ),
            (Method::POST, "/api/v1/metrics/aggregated"),
            (Method::POST, "/api/v1/metrics/pipelines/p/query"),
            (Method::POST, "/api/v1/providers/1/refresh"),
            (Method::POST, "/api/v1/refresh/all"),
            (Method::POST, "/api/v1/vault/unlock"),
        ];

        for (method, path) in allowed {
            assert!(
                !is_blocked(&method, path),
                "{method} {path} should be allowed"
            );
        }
    }

    #[tokio::test]
    async fn rejected_requests_return_the_demo_error() {
        let app = Router::new()
            .route("/api/v1/factory-reset", post(|| async { "ok" }))
            .route("/api/v1/refresh/all", post(|| async { "ok" }))
            .layer(axum::middleware::from_fn(reject_changes));
        let send = |uri: &str| {
            app.clone()
                .oneshot(axum::http::Request::post(uri).body(Body::empty()).unwrap())
        };

        let rejected = send("/api/v1/factory-reset").await.unwrap();
        assert_eq!(rejected.status(), StatusCode::FORBIDDEN);
        let body = axum::body::to_bytes(rejected.into_body(), usize::MAX)
            .await
            .unwrap();
        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
        assert_eq!(body["code"], "DEMO_READ_ONLY");

        let allowed = send("/api/v1/refresh/all").await.unwrap();
        assert_eq!(allowed.status(), StatusCode::OK);
    }
}