AkurAI Build
Menu

Public repository / main

AkurAI-Build / src/server/protection_api.rs

Latest change cd8019fddc46769e797dc54194decfd9d4a98f2d - OLI-270: branch protection — rules per branch, pre-receive check, merge gate by Óli

//! Branch protection routes (OLI-270): rules per repository and the pre-receive check.

use axum::{
    Json,
    extract::{Path, State},
    http::HeaderMap,
};

use super::{ApiError, AppState, Envelope, authorize, authorize_mutation, ok};
use crate::protection::{self, Decision, Push, Rule};

/// Unknown repository or branch name problems are the caller's to fix.
fn store_error(error: anyhow::Error) -> ApiError {
    ApiError::bad(error.to_string())
}

/// `GET /api/repos/{repository}/protection`
pub(super) async fn list_rules(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(repository): Path<String>,
) -> Result<Json<Envelope<Vec<Rule>>>, ApiError> {
    authorize(&headers, &state)?;
    let database = state.database.clone();
    let rules = tokio::task::spawn_blocking(move || protection::rules(&database, &repository))
        .await
        .map_err(ApiError::internal)?
        .map_err(store_error)?;
    Ok(ok(rules))
}

/// `PUT /api/repos/{repository}/protection` with a [`Rule`] body: create or replace.
pub(super) async fn put_rule(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(repository): Path<String>,
    Json(rule): Json<Rule>,
) -> Result<Json<Envelope<Rule>>, ApiError> {
    authorize_mutation(&headers, &state)?;
    let database = state.database.clone();
    let stored =
        tokio::task::spawn_blocking(move || protection::set_rule(&database, &repository, &rule))
            .await
            .map_err(ApiError::internal)?
            .map_err(store_error)?;
    Ok(ok(stored))
}

/// `DELETE /api/repos/{repository}/protection/{branch}`
pub(super) async fn delete_rule(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path((repository, branch)): Path<(String, String)>,
) -> Result<Json<Envelope<bool>>, ApiError> {
    authorize_mutation(&headers, &state)?;
    let database = state.database.clone();
    let removed = tokio::task::spawn_blocking(move || {
        protection::remove_rule(&database, &repository, &branch)
    })
    .await
    .map_err(ApiError::internal)?
    .map_err(store_error)?;
    Ok(ok(removed))
}

/// `POST /api/repos/{repository}/protection/check`: the pre-receive hook's question.
pub(super) async fn check(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(repository): Path<String>,
    Json(push): Json<Push>,
) -> Result<Json<Envelope<Decision>>, ApiError> {
    authorize(&headers, &state)?;
    let database = state.database.clone();
    let decision =
        tokio::task::spawn_blocking(move || protection::check_push(&database, &repository, &push))
            .await
            .map_err(ApiError::internal)?
            .map_err(store_error)?;
    Ok(ok(decision))
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        Router,
        body::{Body, to_bytes},
        http::{Request, StatusCode, header},
        routing::{get, post},
    };
    use tower::ServiceExt;

    fn app() -> Router {
        let state = crate::server::tests::test_state("api-token-123", "webhook-secret");
        state
            .database
            .add_repository("demo", "https://example.com/demo.git", "main")
            .expect("repo");
        Router::new()
            .route(
                "/api/repos/{repository}/protection",
                get(list_rules).put(put_rule),
            )
            .route(
                "/api/repos/{repository}/protection/{branch}",
                axum::routing::delete(delete_rule),
            )
            .route("/api/repos/{repository}/protection/check", post(check))
            .with_state(state)
    }

    async fn send(
        app: &Router,
        method: &str,
        uri: &str,
        token: Option<&str>,
        body: serde_json::Value,
    ) -> (StatusCode, serde_json::Value) {
        let mut request = Request::builder()
            .method(method)
            .uri(uri)
            .header(header::CONTENT_TYPE, "application/json");
        if let Some(token) = token {
            request = request.header(header::AUTHORIZATION, format!("Bearer {token}"));
        }
        let response = app
            .clone()
            .oneshot(request.body(Body::from(body.to_string())).expect("request"))
            .await
            .expect("response");
        let status = response.status();
        let bytes = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
        (status, value)
    }

    #[tokio::test]
    async fn rules_are_set_listed_checked_and_removed_over_http() {
        let app = app();
        let (status, _) = send(
            &app,
            "GET",
            "/api/repos/demo/protection",
            None,
            serde_json::json!(null),
        )
        .await;
        assert_eq!(status, StatusCode::UNAUTHORIZED);
        let (status, body) = send(
            &app,
            "PUT",
            "/api/repos/demo/protection",
            Some("api-token-123"),
            serde_json::json!({"branch": "main", "require_green": true}),
        )
        .await;
        assert_eq!(status, StatusCode::OK, "{body}");
        assert_eq!(body["data"]["require_green"], true);
        assert_eq!(body["data"]["allow_force_push"], false);
        let (_, listed) = send(
            &app,
            "GET",
            "/api/repos/demo/protection",
            Some("api-token-123"),
            serde_json::json!(null),
        )
        .await;
        assert_eq!(listed["data"][0]["branch"], "main");

        let (status, decision) = send(
            &app,
            "POST",
            "/api/repos/demo/protection/check",
            Some("api-token-123"),
            serde_json::json!({"reference": "refs/heads/main", "old": "a".repeat(40), "new": "b".repeat(40), "fast_forward": false}),
        )
        .await;
        assert_eq!(status, StatusCode::OK);
        assert_eq!(decision["data"]["allowed"], false);
        assert!(
            decision["data"]["reason"]
                .as_str()
                .expect("ok")
                .contains("force-push")
        );
        let (_, decision) = send(
            &app,
            "POST",
            "/api/repos/demo/protection/check",
            Some("api-token-123"),
            serde_json::json!({"reference": "refs/heads/topic", "old": "a".repeat(40), "new": "b".repeat(40), "fast_forward": false}),
        )
        .await;
        assert_eq!(decision["data"]["allowed"], true);

        let (status, removed) = send(
            &app,
            "DELETE",
            "/api/repos/demo/protection/main",
            Some("api-token-123"),
            serde_json::json!(null),
        )
        .await;
        assert_eq!(status, StatusCode::OK);
        assert_eq!(removed["data"], true);
        let (status, _) = send(
            &app,
            "PUT",
            "/api/repos/nope/protection",
            Some("api-token-123"),
            serde_json::json!({"branch": "main"}),
        )
        .await;
        assert_eq!(status, StatusCode::BAD_REQUEST);
    }
}