//! Runtime auth: prove mcptest actually sends the bearer token on the wire
//! and that a server rejecting it fails the run.
//!
//! `cli_auth_flags` covers flag parsing and the doctor preflight, but nothing
//! there verifies that a live connect carries `Authorization: Bearer <token>`.
//! These tests stand up a wiremock HTTP MCP endpoint that *requires* the
//! header: authed requests get a normal JSON-RPC reply, everything else gets
//! 401. So the suite passes only when mcptest resolved `bearer_token_env` and
//! put it on every request, and fails (auth error) when the token is absent.

use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::{json, Value};
use wiremock::matchers::{header, method};
use wiremock::{Mock, MockServer, Request, ResponseTemplate};

fn mcptest() -> Command {
    Command::cargo_bin("mcptest").expect("binary builds")
}

/// JSON-RPC reply for a method, echoing the request id.
fn reply(result: Value) -> impl Fn(&Request) -> ResponseTemplate {
    move |req: &Request| {
        let parsed: Value = serde_json::from_slice(&req.body).unwrap_or(Value::Null);
        let id = parsed.get("id").cloned().unwrap_or(Value::Null);
        let envelope = json!({ "jsonrpc": "2.0", "id": id, "result": result.clone() });
        ResponseTemplate::new(200)
            .set_body_string(serde_json::to_string(&envelope).unwrap())
            .insert_header("content-type", "application/json")
    }
}

/// Mount an MCP endpoint that only answers when the request carries
/// `Authorization: Bearer <token>`; any other POST gets 401. The authed mocks
/// take priority so a correctly-authenticated request never falls through to
/// the 401 catch-all.
async fn mount_auth_required(server: &MockServer, token: &str) {
    let bearer = format!("Bearer {token}");
    let authed = |m: &'static str, result: Value| {
        Mock::given(method("POST"))
            .and(header("authorization", bearer.as_str()))
            .and(wiremock::matchers::body_partial_json(
                json!({ "method": m }),
            ))
            .respond_with(reply(result))
            .with_priority(1)
    };
    authed(
        "initialize",
        json!({
            "protocolVersion": "2025-06-18",
            "serverInfo": { "name": "secured", "version": "0.1" },
            "capabilities": { "tools": {} }
        }),
    )
    .mount(server)
    .await;
    authed(
        "tools/list",
        json!({ "tools": [{ "name": "ping", "description": "Returns ok.", "inputSchema": { "type": "object" } }] }),
    )
    .mount(server)
    .await;
    authed(
        "tools/call",
        json!({ "content": [{ "type": "text", "text": "ok" }], "isError": false }),
    )
    .mount(server)
    .await;
    // Authed catch-all: a correctly-authenticated POST for any other method (for
    // example the `notifications/initialized` notification) is accepted with a
    // 202, the way a real server acknowledges a fire-and-forget message.
    Mock::given(method("POST"))
        .and(header("authorization", bearer.as_str()))
        .respond_with(ResponseTemplate::new(202))
        .with_priority(5)
        .mount(server)
        .await;
    // Catch-all: anything without the right bearer is unauthorized.
    Mock::given(method("POST"))
        .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
        .with_priority(10)
        .mount(server)
        .await;
}

fn suite(url: &str) -> String {
    format!(
        r#"servers:
  secured:
    url: "{url}"
    auth:
      bearer_token_env: MCPTEST_RUNTIME_TOKEN
tools:
  - name: authenticated tool call
    server: secured
    tool: ping
    expect:
      - target: result.content[0].text
        matcher: {{ exact: ok }}
"#
    )
}

#[tokio::test(flavor = "multi_thread")]
async fn run_authenticates_with_bearer_token_env() {
    let server = MockServer::start().await;
    mount_auth_required(&server, "secret-xyz").await;
    let dir = tempfile::tempdir().unwrap();
    let cfg = dir.path().join("mcptest.yaml");
    std::fs::write(&cfg, suite(&server.uri())).unwrap();

    // Token present and correct: every request carries the bearer, so the
    // handshake and the tool call succeed.
    mcptest()
        .env("MCPTEST_RUNTIME_TOKEN", "secret-xyz")
        .args(["run", "--config", cfg.to_str().unwrap()])
        .assert()
        .success()
        .stdout(predicate::str::contains("1 passed"));
}

#[tokio::test(flavor = "multi_thread")]
async fn run_fails_when_bearer_token_is_missing() {
    let server = MockServer::start().await;
    mount_auth_required(&server, "secret-xyz").await;
    let dir = tempfile::tempdir().unwrap();
    let cfg = dir.path().join("mcptest.yaml");
    std::fs::write(&cfg, suite(&server.uri())).unwrap();

    // The env var is unset, so no Authorization header is sent: the server
    // answers 401 and the run fails rather than silently passing.
    mcptest()
        .env_remove("MCPTEST_RUNTIME_TOKEN")
        .args(["run", "--config", cfg.to_str().unwrap()])
        .assert()
        .failure();
}

#[tokio::test(flavor = "multi_thread")]
async fn run_fails_when_bearer_token_is_wrong() {
    let server = MockServer::start().await;
    mount_auth_required(&server, "secret-xyz").await;
    let dir = tempfile::tempdir().unwrap();
    let cfg = dir.path().join("mcptest.yaml");
    std::fs::write(&cfg, suite(&server.uri())).unwrap();

    // A present-but-wrong token also gets 401: the run must not pass.
    mcptest()
        .env("MCPTEST_RUNTIME_TOKEN", "wrong-token")
        .args(["run", "--config", cfg.to_str().unwrap()])
        .assert()
        .failure();
}

#[tokio::test(flavor = "multi_thread")]
async fn introspection_fails_fast_with_auth_error_on_401() {
    let server = MockServer::start().await;
    mount_auth_required(&server, "secret-xyz").await;

    // No token at all: the handshake POST gets 401. The transport must surface
    // the HTTP status as a JSON-RPC error so the call resolves at once, instead
    // of waiting out the request timeout and reporting a misleading "timed out".
    mcptest()
        .env_remove("MCPTEST_RUNTIME_TOKEN")
        .args(["tools", "--url", &server.uri()])
        .assert()
        .failure()
        .stderr(
            predicate::str::contains("401")
                .and(predicate::str::contains("requires authentication"))
                .and(predicate::str::contains("timed out").not()),
        );
}

#[tokio::test(flavor = "multi_thread")]
async fn introspection_sends_bearer_via_flag() {
    let server = MockServer::start().await;
    mount_auth_required(&server, "secret-xyz").await;

    // `tools --url --bearer-token-env` is the imperative path: the flag names
    // the env var, mcptest resolves it and authenticates the introspection call.
    mcptest()
        .env("MCPTEST_RUNTIME_TOKEN", "secret-xyz")
        .args([
            "tools",
            "--url",
            &server.uri(),
            "--bearer-token-env",
            "MCPTEST_RUNTIME_TOKEN",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("ping"));
}
