aa-gateway 0.0.1-beta.1

Control plane — policy enforcement engine and agent registry for Agent Assembly
//! AAASM-1591 / Epic 18 S-J #1 — end-to-end verification of
//! `GET /api/v1/admin/status` through the real remote-mode router.
//!
//! Boots an in-process Axum listener exposing
//! `aa_gateway::remote_mode::router(Some(backend), database_url)`,
//! exercises the admin_status endpoint with a real SQLite backend, and
//! asserts the documented wire shape (backend / health / latency_ms /
//! row_counts / no timescaledb on sqlite, password redacted when the
//! caller seeds a postgres-style URL).

use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;

use aa_gateway::storage::{SqliteBackend, SqliteConfig, StorageBackend};
use axum_server::Handle;

async fn open_test_backend(tmp: &tempfile::TempDir) -> Arc<dyn StorageBackend> {
    let path = tmp.path().join("local.db");
    let backend = SqliteBackend::open(&SqliteConfig { path })
        .await
        .expect("open sqlite backend");
    backend.migrate().await.expect("migrate");
    Arc::new(backend)
}

#[tokio::test]
async fn admin_status_returns_documented_storage_block_through_router() {
    let tmp = tempfile::tempdir().expect("create tempdir");
    let backend = open_test_backend(&tmp).await;

    // A postgres-style URL is wired so we can assert the redaction step,
    // even though the actual backend behind the Arc is sqlite. The route's
    // contract is: `database_url` is the operator-configured value passed
    // through redact_database_url before serialisation.
    let app = aa_gateway::remote_mode::router(
        Some(backend),
        Some("postgresql://aasm:secret@db.internal:5432/aasm".to_string()),
    )
    .into_make_service();

    let handle: Handle<SocketAddr> = Handle::new();
    let shutdown_handle = handle.clone();
    let probe_handle = handle.clone();

    let server = tokio::spawn(async move {
        axum_server::bind("127.0.0.1:0".parse().expect("listen_addr"))
            .handle(handle)
            .serve(app)
            .await
            .expect("serve admin_status_e2e router");
    });

    // Wait for the listener to bind before issuing the request.
    let listening = tokio::time::timeout(Duration::from_secs(5), probe_handle.listening())
        .await
        .expect("listener must bind within 5s")
        .expect("listener address");

    let url = format!("http://{listening}/api/v1/admin/status");
    let body: serde_json::Value = reqwest::Client::new()
        .get(&url)
        .send()
        .await
        .expect("GET /api/v1/admin/status")
        .json()
        .await
        .expect("response body must be JSON");

    assert_eq!(body["mode"], "remote");
    assert!(body["version"].is_string(), "version must be string");
    assert!(body["uptime_secs"].is_number(), "uptime_secs must be number");

    let storage = &body["storage"];
    assert_eq!(storage["backend"], "sqlite");
    assert_eq!(storage["health"], "ok");
    assert!(
        storage["latency_ms"].as_u64().is_some(),
        "latency_ms must be a number, got {:?}",
        storage["latency_ms"]
    );

    // sqlite branch: path may be present (None here because the route
    // passes None for sqlite_path in remote mode), database_url omitted.
    assert!(
        storage.get("database_url").is_none(),
        "sqlite branch must omit database_url, got {:?}",
        storage["database_url"]
    );
    assert!(
        storage.get("timescaledb").is_none(),
        "no TimescaleDB on sqlite, got {:?}",
        storage["timescaledb"]
    );

    // Row counts must include all three documented keys.
    let counts = &storage["row_counts"];
    assert!(counts["audit_events_hot"].as_u64().is_some());
    assert!(counts["agents"].as_u64().is_some());
    assert!(counts["policy_versions"].as_u64().is_some());

    shutdown_handle.graceful_shutdown(Some(Duration::from_secs(1)));
    let _ = server.await;
}