libnoa 0.4.0

AI-native distributed version control
Documentation
use std::sync::Arc;

use axum::{
    body::Body,
    extract::ConnectInfo,
    http::{Method, Request, StatusCode},
};
use tower::ServiceExt;

use libnoa::server::{router, AppState};

const TEST_API_TOKEN: &str = "test-token-for-ci";

async fn make_app() -> (tempfile::TempDir, axum::Router) {
    let tmp = tempfile::TempDir::new().unwrap();
    let db = Arc::new(
        redb::Database::builder()
            .create(tmp.path().join("server-test.redb"))
            .unwrap(),
    );
    let state = AppState::new(db).with_api_token(TEST_API_TOKEN.to_string());
    let app = router(state);
    (tmp, app)
}

fn make_request(method: Method, uri: &str, body: Option<String>) -> Request<Body> {
    let mut builder = Request::builder()
        .method(method)
        .uri(uri)
        .header("Authorization", format!("Bearer {}", TEST_API_TOKEN));
    if let Some(b) = body {
        builder = builder.header("content-type", "application/json");
        builder.body(Body::from(b)).unwrap()
    } else {
        builder.body(Body::empty()).unwrap()
    }
}

#[tokio::test]
async fn test_list_refs_empty() {
    let (_tmp, app) = make_app().await;
    let req = make_request(Method::GET, "/api/v1/refs", None);
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_push_ref() {
    let (_tmp, app) = make_app().await;
    // First create a snapshot to get a valid ID
    let expected_id = libnoa::snapshot::content_addressed_snapshot_id_with_ts(
        "tree123",
        &[],
        "default",
        "test",
        "test snapshot",
        1000,
    );
    let snap_body = format!(
        r#"{{"snapshot": {{"id": "{}", "tree_hash": "tree123", "parents": [], "workspace": "default", "author": "test", "timestamp": 1000, "message": "test snapshot"}}}}"#,
        expected_id
    );
    let snap_req = make_request(Method::POST, "/api/v1/snapshots", Some(snap_body));
    let snap_resp = app.clone().oneshot(snap_req).await.unwrap();
    assert_eq!(snap_resp.status(), StatusCode::CREATED);

    let body = format!(r#"{{"name": "main", "id": "{}"}}"#, expected_id);
    let req = make_request(Method::POST, "/api/v1/refs", Some(body));
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
}

#[tokio::test]
async fn test_push_ref_valid_slash_name() {
    let (_tmp, app) = make_app().await;
    let expected_id = libnoa::snapshot::content_addressed_snapshot_id_with_ts(
        "tree456",
        &[],
        "default",
        "test",
        "test",
        1000,
    );
    let snap_body = format!(
        r#"{{"snapshot": {{"id": "{}", "tree_hash": "tree456", "parents": [], "workspace": "default", "author": "test", "timestamp": 1000, "message": "test"}}}}"#,
        expected_id
    );
    let snap_req = make_request(Method::POST, "/api/v1/snapshots", Some(snap_body));
    let snap_resp = app.clone().oneshot(snap_req).await.unwrap();
    assert_eq!(snap_resp.status(), StatusCode::CREATED);

    let body = format!(r#"{{"name": "refs/heads/main", "id": "{}"}}"#, expected_id);
    let req = make_request(Method::POST, "/api/v1/refs", Some(body));
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
}

#[tokio::test]
async fn test_upload_blob_and_get() {
    let (_tmp, app) = make_app().await;

    use base64::Engine;
    let content = base64::engine::general_purpose::STANDARD.encode(b"hello noa server");
    let upload_body = format!(r#"{{"blobs": [{{"content": "{}"}}]}}"#, content);
    let req = make_request(Method::POST, "/api/v1/blobs", Some(upload_body));
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let body_bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
        .await
        .unwrap();
    let result: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
    let hash = result["ids"][0].as_str().unwrap().to_string();
    assert!(!hash.is_empty());
}

#[tokio::test]
async fn test_get_blob_not_found() {
    let (_tmp, app) = make_app().await;
    let req = make_request(
        Method::GET,
        "/api/v1/blob/deadbeef00000000deadbeef00000000deadbeef00000000deadbeef00000000",
        None,
    );
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn test_get_tree_not_found() {
    let (_tmp, app) = make_app().await;
    let req = make_request(
        Method::GET,
        "/api/v1/tree/deadbeef00000000deadbeef00000000deadbeef00000000deadbeef00000000",
        None,
    );
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn test_list_workspaces() {
    let (_tmp, app) = make_app().await;
    let req = make_request(Method::GET, "/api/v1/workspaces", None);
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_list_snapshots() {
    let (_tmp, app) = make_app().await;
    let req = make_request(Method::GET, "/api/v1/snapshots", None);
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_create_workspace() {
    let (_tmp, app) = make_app().await;
    let body = r#"{"workspace": {"name": "test-ws", "head": "noa_base", "base": "noa_base", "agent_id": null, "last_seq": 0, "created_at": 1000, "updated_at": 1000}}"#.to_string();
    let req = make_request(Method::POST, "/api/v1/workspaces", Some(body));
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
}

#[tokio::test]
async fn test_upload_trees() {
    let (_tmp, app) = make_app().await;
    let body =
        r#"{"trees": [{"entries": [{"name": "main.rs", "kind": "Blob", "id": "hash123"}]}]}"#
            .to_string();
    let req = make_request(Method::POST, "/api/v1/trees", Some(body));
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_create_snapshot() {
    let (_tmp, app) = make_app().await;
    // Compute the expected content-addressed snapshot ID
    let expected_id = libnoa::snapshot::content_addressed_snapshot_id_with_ts(
        "tree123",
        &[],
        "default",
        "test",
        "test snapshot",
        1000,
    );
    let body = format!(
        r#"{{"snapshot": {{"id": "{}", "tree_hash": "tree123", "parents": [], "workspace": "default", "author": "test", "timestamp": 1000, "message": "test snapshot"}}}}"#,
        expected_id
    );
    let req = make_request(Method::POST, "/api/v1/snapshots", Some(body));
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
}

fn make_request_no_auth(method: Method, uri: &str, body: Option<String>) -> Request<Body> {
    let mut builder = Request::builder().method(method).uri(uri);
    if let Some(b) = body {
        builder = builder.header("content-type", "application/json");
        builder.body(Body::from(b)).unwrap()
    } else {
        builder.body(Body::empty()).unwrap()
    }
}

fn make_request_with_token(
    method: Method,
    uri: &str,
    token: &str,
    body: Option<String>,
) -> Request<Body> {
    let mut builder = Request::builder()
        .method(method)
        .uri(uri)
        .header("Authorization", format!("Bearer {}", token));
    if let Some(b) = body {
        builder = builder.header("content-type", "application/json");
        builder.body(Body::from(b)).unwrap()
    } else {
        builder.body(Body::empty()).unwrap()
    }
}

#[tokio::test]
async fn test_no_token_rejected() {
    let tmp = tempfile::TempDir::new().unwrap();
    let db = Arc::new(
        redb::Database::builder()
            .create(tmp.path().join("no-token-test.redb"))
            .unwrap(),
    );
    let state = AppState::new(db);
    let app = router(state);
    let req = make_request_no_auth(Method::GET, "/api/v1/refs", None);
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn test_wrong_token_rejected() {
    let (_tmp, app) = make_app().await;
    let req = make_request_with_token(Method::GET, "/api/v1/refs", "wrong-token", None);
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn test_missing_auth_header_rejected() {
    let (_tmp, app) = make_app().await;
    let req = make_request_no_auth(Method::GET, "/api/v1/refs", None);
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn test_empty_bearer_rejected() {
    let (_tmp, app) = make_app().await;
    let req = make_request_with_token(Method::GET, "/api/v1/refs", "", None);
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn test_push_ref_invalid_name_rejected() {
    let (_tmp, app) = make_app().await;
    let body = r#"{"name": "../etc/passwd", "id": "noa_test123"}"#.to_string();
    let req = make_request(Method::POST, "/api/v1/refs", Some(body));
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn test_push_ref_name_with_control_chars_rejected() {
    let (_tmp, app) = make_app().await;
    let body = r#"{"name": "main\nextra", "id": "noa_test123"}"#.to_string();
    let req = make_request(Method::POST, "/api/v1/refs", Some(body));
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn test_push_ref_name_starts_with_dot_rejected() {
    let (_tmp, app) = make_app().await;
    let body = r#"{"name": ".hidden", "id": "noa_test123"}"#.to_string();
    let req = make_request(Method::POST, "/api/v1/refs", Some(body));
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn test_push_ref_name_starts_with_dash_rejected() {
    let (_tmp, app) = make_app().await;
    let body = r#"{"name": "-flag", "id": "noa_test123"}"#.to_string();
    let req = make_request(Method::POST, "/api/v1/refs", Some(body));
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn test_push_ref_name_with_double_dot_rejected() {
    let (_tmp, app) = make_app().await;
    let body = r#"{"name": "foo..bar", "id": "noa_test123"}"#.to_string();
    let req = make_request(Method::POST, "/api/v1/refs", Some(body));
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

async fn make_rate_limited_app() -> (tempfile::TempDir, axum::Router) {
    let tmp = tempfile::TempDir::new().unwrap();
    let db = Arc::new(
        redb::Database::builder()
            .create(tmp.path().join("rate-limit-test.redb"))
            .unwrap(),
    );
    let state = AppState::new(db)
        .with_api_token(TEST_API_TOKEN.to_string())
        .with_rate_limit(3, 60);
    let app = router(state);
    (tmp, app)
}

fn make_request_with_connect_info(
    method: Method,
    uri: &str,
    addr: std::net::SocketAddr,
    body: Option<String>,
) -> Request<Body> {
    let mut builder = Request::builder()
        .method(method)
        .uri(uri)
        .header("Authorization", format!("Bearer {}", TEST_API_TOKEN))
        .extension(ConnectInfo(addr));
    if let Some(b) = body {
        builder = builder.header("content-type", "application/json");
        builder.body(Body::from(b)).unwrap()
    } else {
        builder.body(Body::empty()).unwrap()
    }
}

#[tokio::test]
async fn test_rate_limit_per_ip_independent_buckets() {
    let (_tmp, app) = make_rate_limited_app().await;

    let addr_a: std::net::SocketAddr = "127.0.0.1:1111".parse().unwrap();
    let addr_b: std::net::SocketAddr = "127.0.0.2:2222".parse().unwrap();

    for _ in 0..3 {
        let req = make_request_with_connect_info(Method::GET, "/api/v1/refs", addr_a, None);
        let resp = app.clone().oneshot(req).await.unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }

    let req = make_request_with_connect_info(Method::GET, "/api/v1/refs", addr_a, None);
    let resp = app.clone().oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);

    let req = make_request_with_connect_info(Method::GET, "/api/v1/refs", addr_b, None);
    let resp = app.clone().oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}