aion-server 0.31.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
//! Local-loopback HTTP coverage for nested AWL workspace documents.

#[path = "test_support/state_guard.rs"]
mod state_guard;

use std::path::PathBuf;
use std::sync::Arc;

use aion::EngineBuilder;
use aion_server::api::http::http_router;
use aion_server::config::{NamespaceConfig, ServerConfig};
use aion_server::{NamespaceResolver, ServerState};
use aion_store::{EventStore, InMemoryStore};
use axum::{
    body,
    http::{Request, StatusCode},
    response::Response,
};
use serde_json::{Value, json};
use tower::ServiceExt;

use state_guard::StateUnderTest;

type TestError = Box<dyn std::error::Error>;

struct Harness {
    /// Declared first so the engine is stopped before the scratch directory it
    /// was rooted in is removed.
    server: StateUnderTest,
    _scratch: tempfile::TempDir,
    workspace: PathBuf,
    router: axum::Router,
}

impl Harness {
    async fn new() -> Result<Self, TestError> {
        let scratch = private_tempdir()?;
        let config = ServerConfig::from_slice_with_home(b"", scratch.path())?;
        let (_, mut runtime) = config.into_parts();
        let configured = runtime
            .authoring
            .workspace_dir
            .as_deref()
            .ok_or("stock config omitted the AWL workspace")?;
        let workspace = scratch.path().join(configured);
        runtime.authoring.workspace_dir = Some(workspace.clone());
        let store = Arc::new(InMemoryStore::default());
        let event_store: Arc<dyn EventStore> = store;
        let engine = Arc::new(
            EngineBuilder::new()
                .stop_drain_timeout(std::time::Duration::from_secs(5))
                .store_arc(event_store)
                .in_memory_visibility()
                .scheduler_threads(1)
                .build()
                .await?,
        );
        let resolver = NamespaceResolver::from_config(NamespaceConfig::default(), engine);
        let server = StateUnderTest::new(ServerState::from_parts(resolver, runtime));
        let router = http_router(server.state.clone())?;
        Ok(Self {
            server,
            _scratch: scratch,
            workspace,
            router,
        })
    }
}

async fn request(
    router: &axum::Router,
    method: &str,
    uri: &str,
    value: Option<&Value>,
) -> Result<Response, TestError> {
    let mut builder = Request::builder()
        .method(method)
        .uri(uri)
        .header("x-aion-subject", "nested-doc-test")
        .header("x-aion-namespaces", "default")
        .header("x-aion-deploy", "true");
    let payload = match value {
        Some(value) => {
            builder = builder.header("content-type", "application/json");
            body::Body::from(serde_json::to_vec(value)?)
        }
        None => body::Body::empty(),
    };
    Ok(router.clone().oneshot(builder.body(payload)?).await?)
}

async fn request_json(
    router: &axum::Router,
    method: &str,
    uri: &str,
    value: Option<&Value>,
    status: StatusCode,
) -> Result<Value, TestError> {
    let response = request(router, method, uri, value).await?;
    assert_eq!(response.status(), status, "{method} {uri}");
    let bytes = body::to_bytes(response.into_body(), usize::MAX).await?;
    Ok(serde_json::from_slice(&bytes)?)
}

#[tokio::test]
async fn posting_a_nested_document_then_getting_it_over_http() -> Result<(), TestError> {
    let harness = Harness::new().await?;
    let created = request_json(
        &harness.router,
        "POST",
        "/awl/documents",
        Some(&json!({ "name": "order_flow", "directory": "billing/invoices" })),
        StatusCode::CREATED,
    )
    .await?;
    assert_eq!(created["path"], "billing/invoices/order_flow.awl");
    assert!(
        harness
            .workspace
            .join("billing/invoices/order_flow.awl")
            .is_file()
    );

    let read = request_json(
        &harness.router,
        "GET",
        "/awl/documents/billing%2Finvoices%2Forder_flow.awl",
        None,
        StatusCode::OK,
    )
    .await?;
    assert_eq!(read["source"], created["source"]);

    let listed = request_json(
        &harness.router,
        "GET",
        "/awl/documents",
        None,
        StatusCode::OK,
    )
    .await?;
    assert_eq!(listed[0]["path"], "billing/invoices/order_flow.awl");

    let traversal = request_json(
        &harness.router,
        "GET",
        "/awl/documents/billing%2F%2E%2E%2Fescape.awl",
        None,
        StatusCode::BAD_REQUEST,
    )
    .await?;
    assert_eq!(traversal["error_type"], "InvalidDocumentPath");

    let invalid = request_json(
        &harness.router,
        "POST",
        "/awl/documents",
        Some(&json!({ "name": "order_flow", "directory": "Bad/path" })),
        StatusCode::BAD_REQUEST,
    )
    .await?;
    assert_eq!(invalid["error_type"], "InvalidDocumentPath");
    harness.server.shutdown()?;
    Ok(())
}

fn private_tempdir() -> std::io::Result<tempfile::TempDir> {
    let dir = tempfile::tempdir()?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700))?;
    }
    Ok(dir)
}