Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-mcp/tests/common/mod.rs

//! A fake console behind the connector, and an in-process MCP client that
//! drives it the way claude.ai and Claude Code do: register, authorize with a
//! passkey touch, exchange the code with PKCE, then JSON-RPC over `/mcp`.
//! Nothing here touches a network: every request is a `tower::oneshot`.

#![allow(dead_code)]

use std::collections::HashSet;
use std::sync::{Arc, Mutex};

use axum::body::Body;
use axum::Router;
use gunnar_mcp::{
    Answer, Audit, Backend, BoxFuture, Call, Config, Enrolled, Failure, Item, Mcp, Refusal, Repo,
};
use http::{HeaderMap, Request, StatusCode};
use serde_json::{json, Value};
use tower::ServiceExt as _;

pub const ISSUER: &str = "https://gunnar.mock.invalid";

/// What the fake saw.
#[derive(Default)]
pub struct Seen {
    pub calls: Vec<(String, Call)>,
    pub audits: Vec<Audit>,
    pub forgotten: Vec<String>,
    /// Agents whose key gunnar no longer knows.
    pub gone: HashSet<String>,
    pub enrolled: u64,
}

#[derive(Clone, Default)]
pub struct Fake(pub Arc<Mutex<Seen>>);

/// The agent: `<user>#<n>`.
fn user_of(agent: &str) -> &str {
    agent.split('#').next().unwrap_or("")
}

impl Backend for Fake {
    type Agent = String;

    fn passkey_options<'a>(&'a self, _client: &'a str) -> BoxFuture<'a, Result<Value, Refusal>> {
        Box::pin(async {
            Ok(
                json!({ "ceremony_id": "ceremony-1", "publicKey": { "challenge": "AAAA", "userVerification": "required", "allowCredentials": [] } }),
            )
        })
    }

    fn passkey_finish<'a>(
        &'a self,
        touch: &'a Value,
        _expires_unix_ms: i64,
    ) -> BoxFuture<'a, Result<Enrolled<String>, Refusal>> {
        Box::pin(async move {
            let user = match touch.get("id").and_then(Value::as_str) {
                Some("cred-kim") => "kim",
                Some("cred-eve") => "eve",
                _ => {
                    return Err(Refusal {
                        status: StatusCode::UNAUTHORIZED,
                        message: "Gunnar did not accept that passkey.".into(),
                    })
                }
            };
            let mut seen = self.0.lock().unwrap();
            seen.enrolled += 1;
            let agent = format!("{user}#{}", seen.enrolled);
            Ok(Enrolled {
                agent: agent.clone(),
                user: user.into(),
                fingerprint: format!("SHA256:agent{}", seen.enrolled),
            })
        })
    }

    fn call<'a>(
        &'a self,
        agent: &'a String,
        user: &'a str,
        call: &'a Call,
    ) -> BoxFuture<'a, Result<Answer, Failure>> {
        Box::pin(async move {
            let mut seen = self.0.lock().unwrap();
            assert_eq!(user_of(agent), user, "a call is made as its own person");
            if seen.gone.contains(agent) {
                return Err(Failure::KeyGone);
            }
            seen.calls.push((agent.clone(), call.clone()));
            // kim/secret is kim's and private; pub/open is public.
            if let Some(repo) = call.repo() {
                let visible = repo.namespace == "pub" || (repo.namespace == "kim" && user == "kim");
                if !visible || !(repo.name == "secret" || repo.name == "open") {
                    return Err(Failure::Hidden);
                }
            }
            Ok(match call {
                Call::ListRepositories { .. } => {
                    let mut rows = vec![Repo {
                        path: "pub/open".into(),
                        visibility: "public".into(),
                        default_branch: "main".into(),
                        description: "A public one.".into(),
                    }];
                    if user == "kim" {
                        rows.push(Repo {
                            path: "kim/secret".into(),
                            visibility: "private".into(),
                            default_branch: "main".into(),
                            description: "Kim's own.".into(),
                        });
                    }
                    Answer::Repos(rows)
                }
                Call::GetIssue { number, .. } => Answer::Item(Item {
                    number: *number,
                    state: "open".into(),
                    title: "A bug".into(),
                    author: "kim".into(),
                    body: "Reported by kim@mock.invalid".into(),
                    facts: vec![],
                }),
                Call::CommentOnIssue { number, .. } => {
                    Answer::Done(format!("Commented on issue #{number}."))
                }
                _ => Answer::Done("ok".into()),
            })
        })
    }

    fn forget<'a>(&'a self, agent: &'a String) -> BoxFuture<'a, ()> {
        Box::pin(async move { self.0.lock().unwrap().forgotten.push(agent.clone()) })
    }

    fn client_of(&self, parts: &http::request::Parts) -> String {
        parts
            .headers
            .get("x-test-client")
            .and_then(|v| v.to_str().ok())
            .unwrap_or("10.0.0.1")
            .to_owned()
    }

    fn audit(&self, record: &Audit) {
        self.0.lock().unwrap().audits.push(record.clone());
    }
}

pub struct Harness {
    pub app: Router,
    pub fake: Fake,
    pub mcp: Arc<Mcp<Fake>>,
}

pub struct Answered {
    pub status: StatusCode,
    pub headers: HeaderMap,
    pub body: String,
}

impl Answered {
    pub fn json(&self) -> Value {
        serde_json::from_str(&self.body).unwrap_or_else(|e| panic!("not JSON ({e}): {}", self.body))
    }
}

/// A verifier and its S256 challenge.
pub const VERIFIER: &str = "a-test-verifier-that-is-long-enough-for-rfc-7636-yes";

pub struct Connected {
    pub client_id: String,
    pub client_secret: Option<String>,
    pub access: String,
    pub refresh: String,
    pub scope: String,
}

impl Harness {
    pub fn new() -> Harness {
        Harness::with(Config::new(ISSUER))
    }

    pub fn with(config: Config) -> Harness {
        let fake = Fake::default();
        let mcp = Mcp::new(fake.clone(), config);
        Harness {
            app: mcp.router(),
            fake,
            mcp,
        }
    }

    pub async fn send(&self, request: Request<Body>) -> Answered {
        let r = self.app.clone().oneshot(request).await.expect("an answer");
        let status = r.status();
        let headers = r.headers().clone();
        let bytes = axum::body::to_bytes(r.into_body(), 1 << 22)
            .await
            .expect("a body");
        Answered {
            status,
            headers,
            body: String::from_utf8_lossy(&bytes).into_owned(),
        }
    }

    pub async fn get(&self, uri: &str) -> Answered {
        self.send(Request::get(uri).body(Body::empty()).unwrap())
            .await
    }

    pub async fn post_json(&self, uri: &str, body: &Value) -> Answered {
        self.send(
            Request::post(uri)
                .header("content-type", "application/json")
                .body(Body::from(body.to_string()))
                .unwrap(),
        )
        .await
    }

    pub async fn post_form(&self, uri: &str, pairs: &[(&str, &str)]) -> Answered {
        let body: Vec<String> = pairs
            .iter()
            .map(|(k, v)| format!("{k}={}", enc(v)))
            .collect();
        self.send(
            Request::post(uri)
                .header("content-type", "application/x-www-form-urlencoded")
                .body(Body::from(body.join("&")))
                .unwrap(),
        )
        .await
    }

    pub async fn register(&self, body: &Value) -> Answered {
        self.post_json("/oauth/register", body).await
    }

    /// The authorize page for `client_id`; answers the waiting request's id.
    pub async fn authorize(&self, client_id: &str, redirect: &str, scope: &str) -> Answered {
        let uri = format!(
            "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&code_challenge={}&code_challenge_method=S256&state=st-1&scope={}&resource={}",
            enc(client_id),
            enc(redirect),
            gunnar_mcp::oauth::s256(VERIFIER),
            enc(scope),
            enc(&format!("{ISSUER}/mcp"))
        );
        self.get(&uri).await
    }

    /// Register as Claude Code, touch `cred`'s passkey, exchange the code.
    pub async fn connect(&self, cred: &str, scope: &str, tick_write: bool) -> Connected {
        let fixture: Value =
            serde_json::from_str(include_str!("../fixtures/claude-code-dcr.json")).unwrap();
        self.connect_as(&fixture, cred, scope, tick_write).await
    }

    pub async fn connect_as(
        &self,
        registration: &Value,
        cred: &str,
        scope: &str,
        tick_write: bool,
    ) -> Connected {
        let reg = self.register(registration).await;
        assert_eq!(reg.status, StatusCode::CREATED, "{}", reg.body);
        let reg = reg.json();
        let client_id = reg["client_id"].as_str().unwrap().to_owned();
        let client_secret = reg["client_secret"].as_str().map(str::to_owned);
        let redirect = registration["redirect_uris"][0]
            .as_str()
            .unwrap()
            .to_owned();
        let page = self.authorize(&client_id, &redirect, scope).await;
        assert_eq!(page.status, StatusCode::OK, "{}", page.body);
        let request = request_of(&page.body);
        let code = self.touch(&request, cred, tick_write).await;
        let mut form = vec![
            ("grant_type", "authorization_code"),
            ("code", code.as_str()),
            ("redirect_uri", redirect.as_str()),
            ("code_verifier", VERIFIER),
            ("client_id", client_id.as_str()),
        ];
        if let Some(s) = client_secret.as_deref() {
            form.push(("client_secret", s));
        }
        let token = self.post_form("/oauth/token", &form).await;
        assert_eq!(token.status, StatusCode::OK, "{}", token.body);
        let t = token.json();
        Connected {
            client_id,
            client_secret,
            access: t["access_token"].as_str().unwrap().to_owned(),
            refresh: t["refresh_token"].as_str().unwrap().to_owned(),
            scope: t["scope"].as_str().unwrap().to_owned(),
        }
    }

    /// The passkey touch on the consent page; answers the code.
    pub async fn touch(&self, request: &str, cred: &str, tick_write: bool) -> String {
        let options = self
            .post_json("/oauth/authorize/passkey", &json!({ "request": request }))
            .await;
        assert_eq!(options.status, StatusCode::OK, "{}", options.body);
        let ceremony = options.json()["ceremony_id"].as_str().unwrap().to_owned();
        let done = self
            .post_json("/oauth/authorize/approve", &json!({ "request": request, "write": tick_write, "ceremony_id": ceremony, "id": cred, "response": {} }))
            .await;
        assert_eq!(done.status, StatusCode::OK, "{}", done.body);
        let redirect = done.json()["redirect"].as_str().unwrap().to_owned();
        assert!(redirect.contains("state=st-1"), "{redirect}");
        assert!(
            redirect.contains(&format!("iss={}", enc(ISSUER))),
            "{redirect}"
        );
        query_value(&redirect, "code").expect("a code")
    }

    pub async fn rpc(&self, token: &str, method: &str, params: Value) -> Answered {
        let body = json!({ "jsonrpc": "2.0", "id": 7, "method": method, "params": params });
        self.send(
            Request::post("/mcp")
                .header("content-type", "application/json")
                .header("accept", "application/json, text/event-stream")
                .header("authorization", format!("Bearer {token}"))
                .body(Body::from(body.to_string()))
                .unwrap(),
        )
        .await
    }

    pub async fn tool(&self, token: &str, name: &str, arguments: Value) -> Answered {
        self.rpc(
            token,
            "tools/call",
            json!({ "name": name, "arguments": arguments }),
        )
        .await
    }
}

pub fn request_of(page: &str) -> String {
    let at = page
        .find("data-request=\"")
        .expect("a waiting request on the page")
        + "data-request=\"".len();
    page[at..].split('"').next().unwrap().to_owned()
}

pub fn query_value(uri: &str, key: &str) -> Option<String> {
    let q = uri.split_once('?')?.1;
    q.split('&').find_map(|p| {
        p.split_once('=')
            .filter(|(k, _)| *k == key)
            .map(|(_, v)| dec(v))
    })
}

pub fn enc(v: &str) -> String {
    v.bytes()
        .map(|b| {
            if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) {
                (b as char).to_string()
            } else {
                format!("%{b:02X}")
            }
        })
        .collect()
}

fn dec(v: &str) -> String {
    let b = v.as_bytes();
    let mut out = Vec::new();
    let mut i = 0;
    while i < b.len() {
        if b[i] == b'%' && i + 3 <= b.len() {
            out.push(u8::from_str_radix(&v[i + 1..i + 3], 16).unwrap());
            i += 3;
        } else {
            out.push(b[i]);
            i += 1;
        }
    }
    String::from_utf8(out).unwrap()
}

/// The text of a `tools/call` result, and whether it is an error.
pub fn result_text(a: &Answered) -> (String, bool) {
    let v = a.json();
    let r = &v["result"];
    (
        r["content"][0]["text"]
            .as_str()
            .unwrap_or_else(|| panic!("no text: {v}"))
            .to_owned(),
        r["isError"].as_bool().unwrap_or(false),
    )
}