runx-cli 0.6.19

Cargo-installed launcher for the runx governed agent workflow CLI.
Documentation
use super::*;

use runx_runtime::registry::{HttpResponse, RuntimeHttpError};
use std::cell::RefCell;
use std::fs;

#[derive(Default)]
struct StubTransport {
    requests: RefCell<Vec<HttpRequest>>,
    responses: RefCell<Vec<HttpResponse>>,
}

impl StubTransport {
    fn with_responses(responses: Vec<HttpResponse>) -> Self {
        Self {
            requests: RefCell::new(Vec::new()),
            responses: RefCell::new(responses.into_iter().rev().collect()),
        }
    }
}

impl Transport for StubTransport {
    fn send(&self, request: HttpRequest) -> Result<HttpResponse, RuntimeHttpError> {
        self.requests.borrow_mut().push(request);
        Ok(self.responses.borrow_mut().pop().unwrap_or(HttpResponse {
            status: 500,
            body: "missing stub response".to_owned(),
        }))
    }
}

#[test]
fn parses_login_plan() -> Result<(), String> {
    let args = vec![
        OsString::from("login"),
        OsString::from("--api-base-url"),
        OsString::from("https://runx.test/"),
        OsString::from("--provider"),
        OsString::from("github"),
        OsString::from("--for"),
        OsString::from("publish"),
        OsString::from("--allow-local-api"),
        OsString::from("-j"),
    ];
    assert_eq!(
        parse_login_plan(&args)?,
        LoginPlan {
            api_base_url: Some("https://runx.test/".to_owned()),
            provider: Some("github".to_owned()),
            purpose: Some("publish".to_owned()),
            allow_local_api: true,
            json: true,
        }
    );
    Ok(())
}

#[test]
fn login_exchange_stores_encrypted_public_api_token() -> Result<(), Box<dyn std::error::Error>> {
    let temp = tempfile_dir()?;
    let env = BTreeMap::from([("RUNX_HOME".to_owned(), temp.to_string_lossy().to_string())]);
    let transport = StubTransport::with_responses(vec![
        HttpResponse {
            status: 201,
            body: serde_json::json!({
                "status": "pending",
                "session_id": "login_1",
                "login_token": "ticket_1",
                "authorization_url": "https://runx.test/connect/login_1",
                "poll_after_ms": 0
            })
            .to_string(),
        },
        HttpResponse {
            status: 202,
            body: serde_json::json!({
                "status": "pending",
                "session_id": "login_1",
                "poll_after_ms": 0
            })
            .to_string(),
        },
        HttpResponse {
            status: 200,
            body: serde_json::json!({
                "status": "success",
                "session_id": "login_1",
                "principal_id": "user_1",
                "credential_id": "cred_1",
                "token": "rxk_secret"
            })
            .to_string(),
        },
    ]);
    let output = run_login_command_with_transport(
        &LoginPlan {
            api_base_url: Some("https://runx.test/".to_owned()),
            provider: Some("github".to_owned()),
            purpose: Some("publish".to_owned()),
            allow_local_api: false,
            json: true,
        },
        &env,
        &temp,
        &transport,
        |_| {},
    )?;

    assert!(output.contains("\"status\": \"success\""));
    let config = fs::read_to_string(temp.join("config.json"))?;
    assert!(config.contains("api_token_ref"));
    assert!(!config.contains("rxk_secret"));

    let requests = transport.requests.borrow();
    assert_eq!(requests[0].url, "https://runx.test/v1/login/sessions");
    assert_eq!(requests[0].method, HttpMethod::Post);
    assert_eq!(
        request_json_body(&requests[0])?,
        serde_json::json!({"provider":"github","purpose":"publish"})
    );
    assert_eq!(
        requests[1].url,
        "https://runx.test/v1/login/sessions/login_1/complete"
    );
    assert_eq!(
        request_json_body(&requests[1])?,
        serde_json::json!({"login_token":"ticket_1"})
    );
    assert_eq!(
        requests[2].url,
        "https://runx.test/v1/login/sessions/login_1/complete"
    );
    Ok(())
}

#[test]
fn login_surfaces_api_error() -> Result<(), String> {
    let transport = StubTransport::with_responses(vec![HttpResponse {
        status: 400,
        body: serde_json::json!({
            "status": "error",
            "error": {"code": "login_request_invalid", "detail": "provider must be github"}
        })
        .to_string(),
    }]);
    let error = match run_login_command_with_transport(
        &LoginPlan {
            api_base_url: Some("https://runx.test/".to_owned()),
            provider: Some("bad".to_owned()),
            purpose: None,
            allow_local_api: false,
            json: false,
        },
        &BTreeMap::new(),
        &std::env::temp_dir(),
        &transport,
        |_| {},
    ) {
        Ok(_) => return Err("login should fail".to_owned()),
        Err(error) => error,
    };
    assert!(error.to_string().contains("[login_request_invalid]"));
    Ok(())
}

fn request_json_body(request: &HttpRequest) -> Result<serde_json::Value, serde_json::Error> {
    serde_json::from_str(request.body.as_deref().unwrap_or_default())
}

fn tempfile_dir() -> Result<std::path::PathBuf, std::io::Error> {
    let path = std::env::temp_dir().join(format!(
        "runx-cli-login-{}-{}",
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_nanos()
    ));
    fs::create_dir_all(&path)?;
    Ok(path)
}