use super::testing::{run_bash_with_test_askpass, write_fixtures};
use super::{HELPER_NAME, PROMPT_TITLE};
use crate::tools::test_helpers::create_tool_context;
use crate::tools::{PromptKind, SecretPromptHandle, SecretPromptQueue, SecretPromptRequest};
use std::path::PathBuf;
use std::time::Duration;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver};
struct Rig {
bin: tempfile::TempDir,
helper: PathBuf,
}
fn rig() -> Rig {
let bin = tempfile::tempdir().unwrap();
let helper = write_fixtures(bin.path()).unwrap();
Rig { bin, helper }
}
impl Rig {
async fn run(&self, body: &str, ctx: crate::tools::ToolContext) -> crate::Result<String> {
let command = format!(
"export PATH=\"{}:$PATH\"\n{body}",
self.bin.path().display()
);
run_bash_with_test_askpass(&command, Some(30), ctx, &self.helper).await
}
}
fn prompts() -> (SecretPromptHandle, UnboundedReceiver<SecretPromptRequest>) {
let (tx, rx) = unbounded_channel();
(SecretPromptHandle::new(tx), rx)
}
fn user(
mut rx: UnboundedReceiver<SecretPromptRequest>,
answers: &[Option<&str>],
) -> tokio::task::JoinHandle<Vec<(String, String)>> {
let mut answers: Vec<Option<String>> = answers.iter().map(|a| a.map(str::to_string)).collect();
answers.reverse();
tokio::spawn(async move {
let mut shown = Vec::new();
while let Some(req) = rx.recv().await {
shown.push((req.title.clone(), req.prompt.clone()));
let answer = answers.pop().expect("an unexpected extra prompt");
let _ = req.response_tx.send(answer);
}
assert!(answers.is_empty(), "prompts never raised: {answers:?}");
shown
})
}
#[tokio::test]
async fn the_prompt_names_the_command_and_the_password_reaches_only_sudo() {
let rig = rig();
let (handle, rx) = prompts();
let user = user(rx, &[Some("swordfish")]);
let channel = crate::tools::output::delta_channel();
let (delta_tx, mut delta_rx) = (channel.sender, channel.receiver);
let mut ctx = create_tool_context();
ctx.capabilities.secret_prompt = Some(handle);
ctx.channels.tx_delta = Some(delta_tx);
let out = rig
.run(
r#"sudo echo AUTH_OK
if read -r x; then echo "stdin gave: $x"; else echo stdin-eof; fi"#,
ctx,
)
.await
.unwrap();
let shown = user.await.unwrap();
let mut streamed = String::new();
while let Some(delta) = delta_rx.try_drain() {
streamed.push_str(&delta);
}
assert!(out.contains("AUTH_OK"), "sudo got the password: {out}");
assert!(
out.contains("stdin-eof"),
"the command's stdin got nothing: {out}"
);
assert!(!out.contains("swordfish") && !streamed.contains("swordfish"));
assert_eq!(shown.len(), 1);
assert_eq!(shown[0].0, PROMPT_TITLE);
let body = &shown[0].1;
assert!(
body.starts_with("sudo is asking for your password to run:\nsudo "),
"{body}"
);
assert!(body.contains(" -A echo AUTH_OK\n"), "{body}");
assert!(body.ends_with("\n[sudo] password for tester:"), "{body}");
}
#[tokio::test]
async fn a_password_the_command_echoes_is_redacted() {
let rig = rig();
let (handle, rx) = prompts();
let user = user(rx, &[Some("swordfish")]);
let mut ctx = create_tool_context();
ctx.capabilities.secret_prompt = Some(handle);
let out = rig
.run("FAKE_SUDO_ECHO=1 sudo true; echo done", ctx)
.await
.unwrap();
user.await.unwrap();
assert!(out.contains("sudo got: [redacted]"), "{out}");
assert!(!out.contains("swordfish"), "{out}");
}
#[tokio::test]
async fn sequential_requests_are_each_asked() {
let rig = rig();
let (handle, rx) = prompts();
let user = user(rx, &[Some("wrong"), Some("swordfish")]);
let mut ctx = create_tool_context();
ctx.capabilities.secret_prompt = Some(handle);
let out = rig
.run("sudo echo first; sudo echo second", ctx)
.await
.unwrap();
assert_eq!(user.await.unwrap().len(), 2);
assert!(out.contains("incorrect password attempt"), "{out}");
assert!(!out.contains("first") && out.contains("second"), "{out}");
assert!(
!out.contains("wrong") && !out.contains("swordfish"),
"{out}"
);
}
#[tokio::test]
async fn a_cancelled_prompt_stops_the_command() {
let rig = rig();
let (handle, rx) = prompts();
let user = user(rx, &[None]);
let mut ctx = create_tool_context();
ctx.capabilities.secret_prompt = Some(handle);
let started = std::time::Instant::now();
let err = rig
.run("sudo echo nope; sleep 20; echo should-not-run", ctx)
.await
.unwrap_err()
.to_string();
user.await.unwrap();
assert!(err.contains("waiting for password"), "{err}");
assert!(!err.contains("should-not-run"), "{err}");
assert!(started.elapsed() < Duration::from_secs(15));
}
#[tokio::test]
async fn without_a_prompt_capability_sudo_reports_its_own_error() {
let rig = rig();
let ctx = create_tool_context();
assert!(ctx.capabilities.secret_prompt.is_none());
let out = rig.run("sudo echo nope; echo after:$?", ctx).await.unwrap();
assert!(out.contains("no interactive client"), "{out}");
assert!(out.contains("sudo: no password was provided"), "{out}");
assert!(out.contains("after:1") && !out.contains("nope\n"), "{out}");
}
#[tokio::test]
async fn lookalike_output_raises_no_prompt_with_the_broker_active() {
let rig = rig();
let (handle, mut rx) = prompts();
let mut ctx = create_tool_context();
ctx.capabilities.secret_prompt = Some(handle);
let out = rig
.run(
r#"printf '[sudo] password for x: ' >&2
printf 'Password: ' >&2
if read -r got; then echo "stdin gave: $got"; else echo stdin-eof; fi"#,
ctx,
)
.await
.unwrap();
assert!(out.contains("stdin-eof"), "{out}");
assert!(rx.try_recv().is_err(), "no prompt was raised");
}
#[tokio::test]
async fn the_in_process_tui_queue_answers_it() {
let rig = rig();
let (tx, rx) = unbounded_channel();
let rx = std::sync::Arc::new(std::sync::Mutex::new(rx));
let ui = tokio::spawn(async move {
let mut queue = SecretPromptQueue::new();
let deadline = std::time::Instant::now() + Duration::from_secs(20);
while !queue.is_active() {
assert!(
std::time::Instant::now() < deadline,
"no prompt reached the TUI queue"
);
tokio::time::sleep(Duration::from_millis(10)).await;
queue.poll_requests(&rx);
}
let active = queue.active().unwrap();
let seen = (active.kind, active.title.clone(), active.prompt.clone());
for ch in "swordfish".chars() {
queue.push_char(ch);
}
queue.submit();
seen
});
let mut ctx = create_tool_context();
ctx.capabilities.secret_prompt = Some(SecretPromptHandle::new(tx));
let out = rig.run("sudo echo AUTH_OK", ctx).await.unwrap();
let (kind, title, prompt) = ui.await.unwrap();
assert_eq!(kind, PromptKind::Secret);
assert_eq!(title, PROMPT_TITLE);
assert!(prompt.contains("sudo -A echo AUTH_OK"), "{prompt}");
assert!(out.contains("AUTH_OK"), "{out}");
}
#[tokio::test]
async fn the_command_points_sudo_at_a_private_helper_link() {
let rig = rig();
let out = rig
.run(
r#"echo "link=$SUDO_ASKPASS"; stat -c 'mode=%a' "$(dirname "$SUDO_ASKPASS")""#,
create_tool_context(),
)
.await
.unwrap();
let link = out
.lines()
.find_map(|l| l.strip_prefix("link="))
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{out}"));
assert_eq!(link.file_name().unwrap(), HELPER_NAME);
assert!(out.contains("mode=700"), "{out}");
assert!(!link.parent().unwrap().exists(), "removed after the call");
}