use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_supercode"))
}
fn fresh_dir(tag: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"supercode-mcpoauth-{tag}-{}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn run(supercode_home: &Path, args: &[&str]) -> Output {
Command::new(bin())
.env("SUPERCODE_HOME", supercode_home)
.env("HOME", supercode_home) .env_remove("OPENROUTER_API_KEY")
.env_remove("OPENAI_API_KEY")
.env_remove("ANTHROPIC_API_KEY")
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.expect("failed to spawn the supercode binary")
}
fn stderr(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).into_owned()
}
async fn spawn_mock_oauth_server() -> std::net::SocketAddr {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
let mut buf = vec![0u8; 4096];
let n = match sock.read(&mut buf).await {
Ok(n) => n,
Err(_) => return,
};
let text = String::from_utf8_lossy(&buf[..n]);
let path = text
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.unwrap_or("");
let body = if path.starts_with("/device") {
serde_json::json!({
"device_code": "devcode123",
"user_code": "ABCD-EFGH",
"verification_uri": "http://example.invalid/verify",
"interval": 1,
"expires_in": 60
})
.to_string()
} else {
serde_json::json!({
"access_token": "mock-access-token-xyz",
"refresh_token": "mock-refresh-token-abc",
"expires_in": 3600
})
.to_string()
};
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.flush().await;
});
}
});
addr
}
fn seed_oauth_server_def(supercode_home: &Path, addr: std::net::SocketAddr) {
let mcp_json = serde_json::json!({
"mcpServers": {
"testsrv": {
"transport": "http",
"url": format!("http://127.0.0.1:{}/mcp", addr.port()),
"oauth": {
"device_authorization_endpoint": format!("http://127.0.0.1:{}/device", addr.port()),
"token_endpoint": format!("http://127.0.0.1:{}/token", addr.port()),
"client_id": "test-client"
}
}
}
});
std::fs::write(
supercode_home.join("mcp.json"),
serde_json::to_string_pretty(&mcp_json).unwrap(),
)
.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_login_stores_tokens_at_supercode_home_with_owner_only_permissions() {
let home = fresh_dir("login");
let addr = spawn_mock_oauth_server().await;
seed_oauth_server_def(&home, addr);
let out = run(&home, &["mcp", "login", "testsrv"]);
assert!(
out.status.success(),
"mcp login should succeed against the mock server: {}",
stderr(&out)
);
let token_path = home.join("mcp_oauth.json");
assert!(
token_path.exists(),
"tokens must be written to $SUPERCODE_HOME/mcp_oauth.json"
);
let contents = std::fs::read_to_string(&token_path).unwrap();
assert!(contents.contains("mock-access-token-xyz"));
assert!(
!stderr(&out).contains("mock-access-token-xyz"),
"the raw access token must not be printed: {}",
stderr(&out)
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&token_path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"mcp_oauth.json must be owner-only (0600), got {mode:o}"
);
}
std::fs::remove_dir_all(&home).ok();
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_logout_removes_stored_tokens() {
let home = fresh_dir("logout");
let addr = spawn_mock_oauth_server().await;
seed_oauth_server_def(&home, addr);
let login_out = run(&home, &["mcp", "login", "testsrv"]);
assert!(login_out.status.success(), "{}", stderr(&login_out));
let token_path = home.join("mcp_oauth.json");
let before = std::fs::read_to_string(&token_path).unwrap();
assert!(before.contains("mock-access-token-xyz"));
let logout_out = run(&home, &["mcp", "logout", "testsrv"]);
assert!(logout_out.status.success(), "{}", stderr(&logout_out));
let after = std::fs::read_to_string(&token_path).unwrap();
assert!(
!after.contains("mock-access-token-xyz"),
"logout must remove the stored token: {after}"
);
std::fs::remove_dir_all(&home).ok();
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_login_on_a_server_with_no_oauth_block_fails_cleanly_without_writing_a_token_file() {
let home = fresh_dir("nooauth");
let mcp_json = serde_json::json!({
"mcpServers": { "plain": { "command": "echo", "args": ["hi"] } }
});
std::fs::write(
home.join("mcp.json"),
serde_json::to_string_pretty(&mcp_json).unwrap(),
)
.unwrap();
let out = run(&home, &["mcp", "login", "plain"]);
assert!(!out.status.success());
assert!(
stderr(&out).contains("no `oauth` block"),
"{}",
stderr(&out)
);
assert!(
!home.join("mcp_oauth.json").exists(),
"a failed login must never create a token file"
);
std::fs::remove_dir_all(&home).ok();
}