//! OAuth client-credentials auth at connect.
//!
//! Stands up a wiremock token endpoint plus a secured MCP endpoint that only
//! answers when the request carries the minted bearer. A suite that declares
//! `auth.oauth` client_credentials must: fetch the token from the token
//! endpoint using the secret from the env var, then present it on every MCP
//! request. So the run passes only when the whole exchange worked, and fails
//! with a clear error when the secret is absent.

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

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

const MINTED: &str = "minted-access-token";

/// 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 a token endpoint (client_credentials -> MINTED) plus a secured MCP
/// endpoint that requires `Authorization: Bearer MINTED`.
async fn mount(server: &MockServer) {
    // Token endpoint: any client_credentials POST mints the token.
    Mock::given(method("POST"))
        .and(path("/oauth/token"))
        .and(body_string_contains("grant_type=client_credentials"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "access_token": MINTED,
            "token_type": "Bearer",
            "expires_in": 3600
        })))
        .mount(server)
        .await;

    let bearer = format!("Bearer {MINTED}");
    let authed = |m: &'static str, result: Value| {
        Mock::given(method("POST"))
            .and(path("/mcp"))
            .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/call",
        json!({ "content": [{ "type": "text", "text": "ok" }], "isError": false }),
    )
    .mount(server)
    .await;
    // Authed catch-all for the notifications/initialized handshake step.
    Mock::given(method("POST"))
        .and(path("/mcp"))
        .and(header("authorization", bearer.as_str()))
        .respond_with(ResponseTemplate::new(202))
        .with_priority(5)
        .mount(server)
        .await;
    // Anything without the minted bearer is unauthorized.
    Mock::given(method("POST"))
        .and(path("/mcp"))
        .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
        .with_priority(10)
        .mount(server)
        .await;
}

fn suite(base: &str) -> String {
    format!(
        r#"servers:
  api:
    url: "{base}/mcp"
    auth:
      oauth:
        grant: client_credentials
        token_endpoint: "{base}/oauth/token"
        client_id: my-service
        client_secret_env: OAUTH_CC_SECRET
        scope: "mcp.read"
tools:
  - name: authenticated tool call
    server: api
    tool: ping
    expect:
      - target: result.content[0].text
        matcher: {{ exact: ok }}
"#
    )
}

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

    mcptest()
        .env("OAUTH_CC_SECRET", "shh-secret")
        .args(["run", "--config", cfg.to_str().unwrap()])
        .assert()
        .success()
        .stdout(predicate::str::contains("1 passed"));
}

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

    mcptest()
        .env_remove("OAUTH_CC_SECRET")
        .args(["run", "--config", cfg.to_str().unwrap()])
        .assert()
        .failure()
        .stderr(predicate::str::contains(
            "client secret env var `OAUTH_CC_SECRET` is not set",
        ));
}

#[tokio::test(flavor = "multi_thread")]
async fn the_minted_secret_value_is_never_printed() {
    // Secret hygiene: the client secret must not leak into any output stream.
    let server = MockServer::start().await;
    mount(&server).await;
    let dir = tempfile::tempdir().unwrap();
    let cfg = dir.path().join("mcptest.yaml");
    std::fs::write(&cfg, suite(&server.uri())).unwrap();

    let assert = mcptest()
        .env("OAUTH_CC_SECRET", "super-secret-value-123")
        .args(["run", "--config", cfg.to_str().unwrap(), "--debug"])
        .assert()
        .success();
    let out = assert.get_output();
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        !combined.contains("super-secret-value-123"),
        "the client secret must never appear in output"
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn neither_the_secret_nor_the_minted_token_leaks_under_debug() {
    // Redaction audit: run an authed suite with --debug (the noisiest path) and
    // confirm that neither the client secret nor the minted access token shows
    // up in stdout or stderr. The token travels as an Authorization header; the
    // transport must not log it in the clear.
    let server = MockServer::start().await;
    mount(&server).await;
    let dir = tempfile::tempdir().unwrap();
    let cfg = dir.path().join("mcptest.yaml");
    std::fs::write(&cfg, suite(&server.uri())).unwrap();

    let assert = mcptest()
        .env("OAUTH_CC_SECRET", "client-secret-AAA")
        .args(["run", "--config", cfg.to_str().unwrap(), "--debug"])
        .assert()
        .success();
    let out = assert.get_output();
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        !combined.contains("client-secret-AAA"),
        "the client secret must never appear in output"
    );
    assert!(
        !combined.contains(MINTED),
        "the minted bearer token must never appear in output, got:\n{combined}"
    );
}
