crates/cli/git-ents/tests/login.rs
login.rshistorycomment on this file
| 1 | //! End-to-end coverage of `git ents login` (`roots.web-signin`) against |
| 2 | //! the real hosted-shaped web state served on a real loopback socket: |
| 3 | //! the same `ents_web::router` the Fly deployment proxies to, the same |
| 4 | //! `commands::login::run` a member's own machine executes. This doubles |
| 5 | //! as the hosted-mount integration test — `build_hosted_state` wired and |
| 6 | //! served end to end, no container needed. |
| 7 | #![allow(clippy::expect_used, reason = "integration test")] |
| 8 | |
| 9 | use std::path::Path; |
| 10 | use std::process::Command; |
| 11 | |
| 12 | use git_ents::root::LocalRoot; |
| 13 | |
| 14 | /// A bare repository with `username`'s freshly-written key enrolled, |
| 15 | /// returning the key path. |
| 16 | fn enrolled_bare(dir: &Path, username: &str, seed: u8) -> std::path::PathBuf { |
| 17 | let bare = dir.join("repo.git"); |
| 18 | let output = Command::new("git") |
| 19 | .args(["init", "--bare"]) |
| 20 | .arg(&bare) |
| 21 | .output() |
| 22 | .expect("git runs"); |
| 23 | assert!(output.status.success(), "{output:?}"); |
| 24 | |
| 25 | use ssh_key::private::{Ed25519Keypair, KeypairData}; |
| 26 | let key_path = dir.join(format!("key_{username}")); |
| 27 | let pair = Ed25519Keypair::from_seed(&[seed; 32]); |
| 28 | let key = ssh_key::PrivateKey::new(KeypairData::from(pair), username).expect("well-formed"); |
| 29 | key.write_openssh_file(&key_path, ssh_key::LineEnding::LF) |
| 30 | .expect("writes"); |
| 31 | |
| 32 | let local = LocalRoot::open(&bare).expect("opens"); |
| 33 | git_ents::commands::members::add(&local, username, None, Some(key_path.clone())) |
| 34 | .expect("enrolls"); |
| 35 | key_path |
| 36 | } |
| 37 | |
| 38 | // @relation(roots.web-signin, roots.single-node-hosted, scope=function, role=Verifies) |
| 39 | #[tokio::test(flavor = "multi_thread")] |
| 40 | async fn git_ents_login_signs_a_hosted_browser_session_in() { |
| 41 | let dir = tempfile::tempdir().expect("tempdir"); |
| 42 | let server_key = enrolled_bare(dir.path(), "server", 42); |
| 43 | // Enroll the human member too, with their own distinct key. |
| 44 | use ssh_key::private::{Ed25519Keypair, KeypairData}; |
| 45 | let member_key = dir.path().join("key_joey"); |
| 46 | let pair = Ed25519Keypair::from_seed(&[7; 32]); |
| 47 | let key = ssh_key::PrivateKey::new(KeypairData::from(pair), "joey").expect("well-formed"); |
| 48 | key.write_openssh_file(&member_key, ssh_key::LineEnding::LF) |
| 49 | .expect("writes"); |
| 50 | let bare = dir.path().join("repo.git"); |
| 51 | let local = LocalRoot::open(&bare).expect("opens"); |
| 52 | git_ents::commands::members::add(&local, "joey", None, Some(member_key.clone())) |
| 53 | .expect("enrolls"); |
| 54 | |
| 55 | // Bind first so the realm's host names the real ephemeral port — |
| 56 | // `git ents login` refuses a host disagreement by design. |
| 57 | let listener = ents_web::bind("127.0.0.1:0".parse().expect("addr")) |
| 58 | .await |
| 59 | .expect("binds"); |
| 60 | let host = listener.local_addr().expect("bound").to_string(); |
| 61 | let root = git_ents::root::HostedRoot::open(&bare).expect("opens"); |
| 62 | let state = git_ents::commands::serve::build_hosted_state(root, server_key, host.clone()) |
| 63 | .expect("boots"); |
| 64 | tokio::spawn(ents_web::serve_on(listener, state)); |
| 65 | |
| 66 | let url = format!("http://{host}"); |
| 67 | let outcome = tokio::task::spawn_blocking(move || { |
| 68 | // The browser half: GET /login mints a session and displays the |
| 69 | // one-time code. |
| 70 | let agent = ureq::agent(); |
| 71 | let mut page = agent |
| 72 | .get(format!("{url}/login")) |
| 73 | .call() |
| 74 | .expect("login page"); |
| 75 | let cookie = page |
| 76 | .headers() |
| 77 | .get("set-cookie") |
| 78 | .expect("a fresh session cookie") |
| 79 | .to_str() |
| 80 | .expect("ascii") |
| 81 | .split(';') |
| 82 | .next() |
| 83 | .expect("cookie pair") |
| 84 | .to_owned(); |
| 85 | let body = page.body_mut().read_to_string().expect("html"); |
| 86 | let code: String = body |
| 87 | .split(&format!("{host} ")) |
| 88 | .nth(1) |
| 89 | .expect("the page displays the login command") |
| 90 | .chars() |
| 91 | .take(9) |
| 92 | .collect(); |
| 93 | |
| 94 | // The CLI half: the real command, against the real socket. |
| 95 | let mut out = Vec::new(); |
| 96 | git_ents::commands::login::run(&url, &code, Some(member_key), &mut out).expect("signs in"); |
| 97 | let printed = String::from_utf8(out).expect("utf8"); |
| 98 | assert!( |
| 99 | printed.contains("authenticated as joey"), |
| 100 | "reports the member: {printed}" |
| 101 | ); |
| 102 | |
| 103 | // The browser half again: the same session now reads signed in. |
| 104 | let mut page = agent |
| 105 | .get(format!("{url}/login")) |
| 106 | .header("cookie", &cookie) |
| 107 | .call() |
| 108 | .expect("login page"); |
| 109 | let body = page.body_mut().read_to_string().expect("html"); |
| 110 | assert!( |
| 111 | body.contains("Signed in as") && body.contains("joey"), |
| 112 | "the browser session is authenticated: {body}" |
| 113 | ); |
| 114 | }) |
| 115 | .await; |
| 116 | outcome.expect("blocking half succeeds"); |
| 117 | } |