mod common;
use std::io::{BufRead, BufReader};
use std::process::{Child, Stdio};
use std::sync::mpsc;
use std::time::Duration;
use common::*;
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()
}
async fn decide(server: &Server, url: &str, decision: &str) -> reqwest::Response {
let (cookie, csrf) = server.session().await;
let form = [
("_csrf", csrf.as_str()),
("code", ¶m(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(¶m(&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());
}
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;
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() {
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);
}
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
}
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")
}
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);
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;
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"
);
}