apiplant-server 0.2.0

apiplant HTTP server: CRUD routing, function endpoints and TLS on ntex
Documentation
//! The assistant's endpoints: that they exist only where a provider does, and
//! that they are not open to the world by default.
//!
//! Nothing here talks to a model. What a completion *contains* is the
//! provider's business and is covered by `apiplant-ai`'s own tests; what
//! matters at this layer is who gets through the door.

use super::*;

/// An app whose `main.toml` has whatever `[ai]` section the test wants.
async fn app_with(label: &str, ai_section: &str) -> (AppState, std::path::PathBuf, TempDatabase) {
    let db = TempDatabase::create(label).await;
    let root = temp_dir(label);
    write_files(
        &root,
        &[(
            "main.toml",
            &format!(
                "\n[server]\nbase_path = \"/api\"\n\n[database]\nurl = \"{}\"\n{ai_section}",
                db.url
            ),
        )],
    );
    let state = load_state(&root).await;
    (state, root, db)
}

async fn app_with_files(
    label: &str,
    ai_section: &str,
    files: &[(&str, &str)],
) -> (AppState, std::path::PathBuf, TempDatabase) {
    let db = TempDatabase::create(label).await;
    let root = temp_dir(label);
    let mut all = vec![(
        "main.toml",
        format!(
            "\n[server]\nbase_path = \"/api\"\n\n[database]\nurl = \"{}\"\n{ai_section}",
            db.url
        ),
    )];
    all.extend(files.iter().map(|(path, contents)| (*path, (*contents).to_string())));
    let refs = all
        .iter()
        .map(|(path, contents)| (*path, contents.as_str()))
        .collect::<Vec<_>>();
    write_files(&root, &refs);
    let state = load_state(&root).await;
    (state, root, db)
}

/// An app with no `[ai]` section has no assistant endpoints at all — the same
/// answer the mailbox flows and `/billing` give when unconfigured.
#[ntex::test]
async fn without_a_provider_there_are_no_ai_endpoints() {
    let (state, root, db) = app_with("noai", "").await;
    assert!(!state.ai_enabled());
    let app = init_http_app!(state);

    // Neither path is served. What answers instead is the generic CRUD scope,
    // which the paths fall through to and which has no `ai` resource — so the
    // assertion is that nothing *succeeds*, not which 4xx the fallthrough
    // happens to produce.
    for request in [
        test::TestRequest::get().uri("/api/ai/config").to_request(),
        req_json("POST", "/api/ai/chat", json!({"messages":[]})),
    ] {
        let status = test::call_service(&app, request).await.status().as_u16();
        assert!(
            (400..500).contains(&status),
            "an app with no assistant answered {status}"
        );
    }

    fs::remove_dir_all(root).unwrap();
    db.cleanup().await;
}

/// Configured, the app describes its assistant to a front end — and asks a
/// caller who they are before spending anything on their behalf.
#[ntex::test]
async fn a_configured_assistant_is_described_publicly_and_answers_nobody_anonymous() {
    let (state, root, db) = app_with(
        "withai",
        "\n[ai]\nprovider = \"custom\"\nendpoint = \"http://localhost:8080\"\nmodel = \"local\"\n",
    )
    .await;
    assert!(state.ai_enabled());
    let app = init_http_app!(state);

    // The config is public: a page that knows the access level can show a
    // sign-in prompt rather than a chat box that would answer 401.
    let response =
        test::call_service(&app, test::TestRequest::get().uri("/api/ai/config").to_request()).await;
    assert_eq!(response.status().as_u16(), 200);
    let config = read_json(response).await;
    assert_eq!(config["provider"], "custom");
    assert_eq!(config["model"], "local");
    assert_eq!(config["access"], "authenticated");
    // The one thing that must never be in it.
    assert!(config.get("api_key").is_none());

    // `[ai] access` defaults to `authenticated`, so an anonymous caller is
    // turned away before the request reaches any provider.
    assert_eq!(
        test::call_service(
            &app,
            req_json(
                "POST",
                "/api/ai/chat",
                json!({"messages":[{"role":"user","content":"hi"}]})
            )
        )
        .await
        .status()
        .as_u16(),
        401
    );

    fs::remove_dir_all(root).unwrap();
    db.cleanup().await;
}

/// `access = "public"` is a decision an app writes down, and it is honoured —
/// the request gets as far as the provider, which in a test is not there.
#[ntex::test]
async fn a_public_assistant_lets_an_anonymous_caller_reach_the_provider() {
    let (state, root, db) = app_with(
        "publicai",
        // Port 1 answers nothing, which is what makes this a transport failure
        // rather than an authorisation one — the distinction being tested.
        "\n[ai]\nprovider = \"custom\"\nendpoint = \"http://127.0.0.1:1\"\naccess = \"public\"\ntimeout_secs = 2\n",
    )
    .await;
    let app = init_http_app!(state);

    let response = test::call_service(
        &app,
        req_json(
            "POST",
            "/api/ai/chat",
            json!({"messages":[{"role":"user","content":"hi"}]}),
        ),
    )
    .await;
    // Not 401 and not 500: the app is fine, the thing behind it is not.
    assert_eq!(response.status().as_u16(), 502);

    fs::remove_dir_all(root).unwrap();
    db.cleanup().await;
}

/// A configured agent is discoverable and, when it stores history, creates a
/// thread plus the caller's message before the provider is even reached.
#[ntex::test]
async fn a_stored_agent_persists_the_thread_even_if_the_provider_refuses() {
    let (state, root, db) = app_with_files(
        "agent",
        "\n[ai]\nprovider = \"custom\"\nendpoint = \"http://127.0.0.1:1\"\nmodel = \"local\"\ntimeout_secs = 2\n",
        &[(
            "agents/coach.toml",
            r#"
[agent]
name = "coach"
description = "A stored coach."
system = "Be concise."
storage.enabled = true

[permissions]
chat = "authenticated"
history = "owner"
"#,
        )],
    )
    .await;
    let app = init_http_app!(state);

    let registration = read_json(
        test::call_service(
            &app,
            req_json(
                "POST",
                "/api/auth/register",
                json!({"email":"ann@example.test","password":"hunter2"}),
            ),
        )
        .await,
    )
    .await;
    let token = registration["token"].as_str().unwrap().to_string();

    let config = read_json(
        test::call_service(&app, test::TestRequest::get().uri("/api/ai/config").to_request()).await,
    )
    .await;
    assert_eq!(config["agents"][0]["name"], "coach");
    assert_eq!(config["agents"][0]["storage"], true);

    let response = test::call_service(
        &app,
        bearer(
            test::TestRequest::post()
                .uri("/api/ai/agents/coach/chat")
                .header(CONTENT_TYPE, "application/json")
                .set_payload(json!({"message":"hello there"}).to_string()),
            &token,
        )
        .to_request(),
    )
    .await;
    assert_eq!(response.status().as_u16(), 502);

    let threads = read_json(
        test::call_service(
            &app,
            bearer(test::TestRequest::get().uri("/api/ai_coach_thread"), &token).to_request(),
        )
        .await,
    )
    .await;
    let threads = threads.as_array().unwrap();
    assert_eq!(threads.len(), 1);

    let messages = read_json(
        test::call_service(
            &app,
            bearer(test::TestRequest::get().uri("/api/ai_coach_message"), &token).to_request(),
        )
        .await,
    )
    .await;
    let messages = messages.as_array().unwrap();
    assert_eq!(messages.len(), 1);
    assert_eq!(messages[0]["role"], "user");
    assert_eq!(messages[0]["content"], "hello there");

    fs::remove_dir_all(root).unwrap();
    db.cleanup().await;
}