semora-cli 1.1.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
mod common;

use std::io::{BufRead, BufReader};
use std::process::{Child, Stdio};
use std::sync::mpsc;
use std::time::Duration;

use common::*;

/// Spawns the CLI with no terminal to read from and streams its stdout lines.
fn spawn_lines(cli: &Cli, args: &[&str]) -> (Child, mpsc::Receiver<String>) {
    let mut cmd = cli.command(args);
    cmd.stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    let mut child = cmd.spawn().unwrap();
    let out = child.stdout.take().unwrap();
    let (tx, rx) = mpsc::channel();
    std::thread::spawn(move || {
        for line in BufReader::new(out).lines().map_while(Result::ok) {
            let _ = tx.send(line);
        }
    });
    (child, rx)
}

async fn wait_line(rx: &mpsc::Receiver<String>, pred: impl Fn(&str) -> bool) -> String {
    let deadline = std::time::Instant::now() + Duration::from_secs(20);
    while std::time::Instant::now() < deadline {
        match rx.try_recv() {
            Ok(l) if pred(&l) => return l,
            Ok(_) => {}
            Err(_) => tokio::time::sleep(Duration::from_millis(30)).await,
        }
    }
    panic!("expected line never came");
}

async fn wait_exit(child: &mut Child) -> (i32, String) {
    let deadline = std::time::Instant::now() + Duration::from_secs(20);
    loop {
        if let Some(status) = child.try_wait().unwrap() {
            let mut err = String::new();
            if let Some(mut e) = child.stderr.take() {
                use std::io::Read;
                let _ = e.read_to_string(&mut err);
            }
            return (status.code().unwrap_or(-1), err);
        }
        assert!(std::time::Instant::now() < deadline, "CLI did not exit");
        tokio::time::sleep(Duration::from_millis(50)).await;
    }
}

fn param(url: &str, key: &str) -> String {
    let u = url::Url::parse(url.trim()).unwrap();
    u.query_pairs()
        .find(|(k, _)| k == key)
        .map(|(_, v)| v.into_owned())
        .unwrap_or_default()
}

/// Acts as the signed-in person allowing (or denying) the code in a browser somewhere else.
async fn decide(server: &Server, url: &str, decision: &str) -> reqwest::Response {
    let (cookie, csrf) = server.session().await;
    let form = [
        ("_csrf", csrf.as_str()),
        ("code", &param(url, "code")),
        ("decision", decision),
    ];
    reqwest::Client::new()
        .post(format!("{}/cli/device", server.url))
        .header("cookie", cookie)
        .header("origin", &server.url)
        .form(&form)
        .send()
        .await
        .unwrap()
}

#[tokio::test(flavor = "multi_thread")]
async fn login_stores_a_key_and_logout_revokes_it() {
    let server = Server::start().await;
    let mut cli = Cli::new(&server);
    cli.key = None;
    let (mut child, rx) = spawn_lines(&cli, &["login", "--name", "test-box"]);
    let url = wait_line(&rx, |l| l.trim_start().starts_with("http")).await;
    assert!(
        url.starts_with(&format!("{}/cli/device?code=", server.url)),
        "a page on the server, nothing on this machine: {url}"
    );
    assert_eq!(param(&url, "code").len(), 9, "{url}");

    let resp = decide(&server, &url, "allow").await;
    assert!(resp.status().is_success());
    assert!(resp.text().await.unwrap().contains("test-box"));
    let (code, err) = wait_exit(&mut child).await;
    assert_eq!(code, 0, "{err}");
    assert!(!err.contains("warning"), "a first login is quiet: {err}");
    assert!(
        err.contains(&param(&url, "code")),
        "the terminal shows the code to check: {err}"
    );

    let creds = std::fs::read_to_string(cli.config.path().join("credentials.toml")).unwrap();
    assert!(
        creds.contains("sm_"),
        "key stored in the file when no keyring: {creds}"
    );
    let who = cli.run(&["whoami", "--json"]).await;
    assert_eq!(who.code, 0, "stored login is used: {}", who.stderr);
    assert_eq!(who.json()["key"]["name"], "test-box");
    assert_eq!(who.json()["key"]["created_via"], "cli-login");
    let key = stored_key(&cli);

    let out = cli.run(&["logout"]).await;
    assert_eq!(out.code, 0, "{}", out.stderr);
    let creds = std::fs::read_to_string(cli.config.path().join("credentials.toml")).unwrap();
    assert!(!creds.contains("sm_"), "forgotten: {creds}");
    cli.key = Some(key);
    assert_eq!(
        cli.run(&["whoami"]).await.code,
        3,
        "and revoked on the server"
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn no_browser_login_is_the_same_sign_in() {
    let server = Server::start().await;
    let mut cli = Cli::new(&server);
    cli.key = None;
    let (mut child, rx) = spawn_lines(&cli, &["login", "--no-browser"]);
    let url = wait_line(&rx, |l| l.trim_start().starts_with("http")).await;
    decide(&server, &url, "allow").await;
    let (status, err) = wait_exit(&mut child).await;
    assert_eq!(status, 0, "{err}");
    assert_eq!(cli.run(&["whoami"]).await.code, 0);
}

#[tokio::test(flavor = "multi_thread")]
async fn a_denied_login_fails_and_stores_nothing() {
    let server = Server::start().await;
    let mut cli = Cli::new(&server);
    cli.key = None;
    let (mut child, rx) = spawn_lines(&cli, &["login"]);
    let url = wait_line(&rx, |l| l.trim_start().starts_with("http")).await;
    decide(&server, &url, "deny").await;
    let (code, err) = wait_exit(&mut child).await;
    assert_eq!(code, 3, "denied is an auth failure: {err}");
    assert!(err.contains("denied"), "{err}");
    assert!(!cli.config.path().join("credentials.toml").exists());
}

/// Serves `app` on a free port, standing in for a server this CLI does not match.
async fn serve(app: axum::Router) -> String {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
    format!("http://{addr}")
}

#[tokio::test(flavor = "multi_thread")]
async fn a_login_nobody_allows_in_time_is_a_timeout() {
    use axum::Json;
    use axum::routing::post;
    // A server whose sign-in has run out by the first poll.
    let url = serve(
        axum::Router::new()
            .route(
                "/api/v1/cli/device",
                post(|| async {
                    Json(serde_json::json!({
                        "device_code": "secret",
                        "user_code": "ABCD-EFGH",
                        "verification_uri": "http://semora.test/cli/device",
                        "verification_uri_complete": "http://semora.test/cli/device?code=ABCD-EFGH",
                        "expires_in": 600,
                        "interval": 1,
                    }))
                }),
            )
            .route(
                "/api/v1/cli/token",
                post(|| async { Json(serde_json::json!({ "status": "expired" })) }),
            ),
    )
    .await;
    let server = Server::start().await;
    let mut cli = Cli::new(&server);
    cli.key = None;
    cli.url = url;
    let out = cli.run(&["login"]).await;
    assert_eq!(out.code, 2, "{}", out.stderr);
    assert!(
        out.stderr.contains("semora login"),
        "says what to do next: {}",
        out.stderr
    );
    assert!(!cli.config.path().join("credentials.toml").exists());
}

#[tokio::test(flavor = "multi_thread")]
async fn a_server_without_the_sign_in_says_so() {
    // Every path answers 404: a server older than this CLI.
    let url = serve(axum::Router::new()).await;
    let server = Server::start().await;
    let mut cli = Cli::new(&server);
    cli.key = None;
    cli.url = url;
    let out = cli.run(&["login"]).await;
    assert_eq!(out.code, 4, "{}", out.stderr);
    assert!(out.stderr.contains("older than this CLI"), "{}", out.stderr);
}

/// Runs `semora login` and answers it as the signed-in person, allowing the code.
async fn allowed_login(server: &Server, cli: &Cli) -> (i32, String) {
    let (mut child, rx) = spawn_lines(cli, &["login"]);
    let url = wait_line(&rx, |l| l.trim_start().starts_with("http")).await;
    decide(server, &url, "allow").await;
    wait_exit(&mut child).await
}

/// The key `credentials.toml` holds.
fn stored_key(cli: &Cli) -> String {
    std::fs::read_to_string(cli.config.path().join("credentials.toml"))
        .unwrap()
        .lines()
        .find_map(|l| l.split_once("api_key = "))
        .map(|(_, v)| v.trim_matches('"').to_string())
        .expect("a stored key")
}

/// `GET /api/v1/whoami` with `key`: 200 while the key works, 401 once it is revoked.
async fn whoami_status(server: &Server, key: &str) -> u16 {
    reqwest::Client::new()
        .get(format!("{}/api/v1/whoami", server.url))
        .bearer_auth(key)
        .send()
        .await
        .unwrap()
        .status()
        .as_u16()
}

#[tokio::test(flavor = "multi_thread")]
async fn logging_in_again_revokes_the_previous_stored_key() {
    let server = Server::start().await;
    let mut cli = Cli::new(&server);
    cli.key = None;
    let (code, err) = allowed_login(&server, &cli).await;
    assert_eq!(code, 0, "{err}");
    assert!(!err.contains("warning"), "nothing was stored before: {err}");
    let first = stored_key(&cli);
    assert_eq!(whoami_status(&server, &first).await, 200);

    // The second login runs with SEMORA_API_KEY set. That key is not the CLI's to revoke.
    cli.key = Some(server.key.clone());
    let (code, err) = allowed_login(&server, &cli).await;
    assert_eq!(code, 0, "{err}");
    assert!(!err.contains("could not revoke"), "{err}");
    let second = stored_key(&cli);
    assert_ne!(first, second, "the second login minted a new key");

    assert_eq!(
        whoami_status(&server, &first).await,
        401,
        "the previous stored key is revoked on the server"
    );
    assert_eq!(whoami_status(&server, &second).await, 200);
    assert_eq!(
        whoami_status(&server, &server.key).await,
        200,
        "the environment's key is left alone"
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn a_denied_login_leaves_the_stored_key_working() {
    let server = Server::start().await;
    let mut cli = Cli::new(&server);
    cli.key = None;
    let (code, err) = allowed_login(&server, &cli).await;
    assert_eq!(code, 0, "{err}");
    let first = stored_key(&cli);

    let (mut child, rx) = spawn_lines(&cli, &["login"]);
    let url = wait_line(&rx, |l| l.trim_start().starts_with("http")).await;
    decide(&server, &url, "deny").await;
    let (code, err) = wait_exit(&mut child).await;
    assert_eq!(code, 3, "{err}");
    assert_eq!(
        stored_key(&cli),
        first,
        "the old key is still the stored one"
    );
    assert_eq!(
        whoami_status(&server, &first).await,
        200,
        "a key is revoked only after its replacement is stored"
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn logging_in_over_a_key_that_is_already_revoked_says_nothing() {
    let server = Server::start().await;
    let mut cli = Cli::new(&server);
    cli.key = None;
    let (code, err) = allowed_login(&server, &cli).await;
    assert_eq!(code, 0, "{err}");
    let first = stored_key(&cli);
    cli.key = Some(first.clone());
    let who = cli.run(&["whoami", "--json"]).await;
    let id = who.json()["key"]["id"].as_i64().unwrap();
    semora_server::auth::apikey::revoke(&server.state.db, None, id)
        .await
        .unwrap();
    assert_eq!(whoami_status(&server, &first).await, 401);

    cli.key = None;
    let (code, err) = allowed_login(&server, &cli).await;
    assert_eq!(code, 0, "{err}");
    assert!(!err.contains("warning"), "already revoked is fine: {err}");
    assert_eq!(whoami_status(&server, &stored_key(&cli)).await, 200);
}

#[tokio::test(flavor = "multi_thread")]
async fn a_previous_key_that_cannot_be_revoked_is_a_warning_not_a_failure() {
    let server = Server::start().await;
    let mut cli = Cli::new(&server);
    cli.key = None;
    // A stored key that works for `whoami` but is not allowed to revoke keys.
    let (_, narrow) = semora_server::auth::apikey::create(
        &server.state,
        server.user_id,
        "read-only",
        &[semora_proto::Scope::DocsRead],
        None,
        "web",
    )
    .await
    .unwrap();
    std::fs::write(
        cli.config.path().join("credentials.toml"),
        format!(
            "default_server = \"{url}\"\n\n[servers.\"{url}\"]\napi_key = \"{narrow}\"\nkey_name = \"read-only\"\nin_keyring = false\n",
            url = server.url
        ),
    )
    .unwrap();
    assert_eq!(whoami_status(&server, &narrow).await, 200);

    let (code, err) = allowed_login(&server, &cli).await;
    assert_eq!(code, 0, "the login still succeeds: {err}");
    assert!(err.contains("could not revoke"), "{err}");
    assert_ne!(stored_key(&cli), narrow, "the new key is stored");
    assert_eq!(whoami_status(&server, &stored_key(&cli)).await, 200);
    assert_eq!(
        whoami_status(&server, &narrow).await,
        200,
        "it could not be revoked"
    );
}