498 lines
16 KiB
Rust
498 lines
16 KiB
Rust
//! Tests for `bxctl`'s admin client and the commands built on it, against a fake `brokerd`.
|
|
//! Do not edit.
|
|
|
|
mod support;
|
|
|
|
use bxctl::admin::{
|
|
AdminError, cmd_approvals, cmd_approve, cmd_grants_check, cmd_refuse, list, reason_name,
|
|
request, write_block,
|
|
};
|
|
use proto::{
|
|
Approve, DecisionRecord, DenyReason, Empty, Envelope, ErrorCode, GrantProblem, GrantsReport,
|
|
Message, PROTOCOL_VERSION, Refuse,
|
|
};
|
|
use std::process::Command;
|
|
use support::{brokerd_with, fake_brokerd, fake_brokerd_frames, pending, ts, wire_error};
|
|
|
|
const BACKSLASH: char = '\\';
|
|
|
|
/// The escape for one code point, built and never spelled out (see `tests/escape.rs`).
|
|
fn esc(code: u32) -> String {
|
|
format!("{BACKSLASH}u{code:04x}")
|
|
}
|
|
|
|
fn text(out: Vec<u8>) -> String {
|
|
String::from_utf8(out).unwrap()
|
|
}
|
|
|
|
const ALLOWED: DecisionRecord = DecisionRecord::Allowed {};
|
|
|
|
// ---- the client ----
|
|
|
|
#[test]
|
|
fn request_sends_one_final_frame_with_id_1_and_returns_the_answer() {
|
|
let fake = fake_brokerd_frames(|request| {
|
|
assert_eq!(request.v, PROTOCOL_VERSION);
|
|
assert_eq!(request.id, 1);
|
|
assert!(request.r#final);
|
|
vec![Envelope {
|
|
v: PROTOCOL_VERSION,
|
|
id: 1,
|
|
r#final: true,
|
|
msg: Message::Ok(Empty {}),
|
|
}]
|
|
});
|
|
let answer = request(&fake.socket, Message::CheckGrants(Empty {})).unwrap();
|
|
assert_eq!(answer, Message::Ok(Empty {}));
|
|
assert_eq!(fake.requests(), vec![Message::CheckGrants(Empty {})]);
|
|
}
|
|
|
|
#[test]
|
|
fn an_error_frame_is_refused_with_its_code_and_detail() {
|
|
let fake = fake_brokerd(|_| wire_error(ErrorCode::Forbidden, "not on this socket"));
|
|
match request(&fake.socket, Message::Approvals(Empty {})) {
|
|
Err(AdminError::Refused(w)) => {
|
|
assert_eq!(w.code, ErrorCode::Forbidden);
|
|
assert_eq!(w.detail, "not on this socket");
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
let e = request(&fake.socket, Message::Approvals(Empty {})).unwrap_err();
|
|
assert_eq!(e.to_string(), "forbidden: not on this socket");
|
|
}
|
|
|
|
#[test]
|
|
fn an_answer_that_is_not_final_or_has_another_id_is_a_protocol_error() {
|
|
let not_final = fake_brokerd_frames(|request| {
|
|
vec![Envelope {
|
|
v: PROTOCOL_VERSION,
|
|
id: request.id,
|
|
r#final: false,
|
|
msg: Message::Ok(Empty {}),
|
|
}]
|
|
});
|
|
assert!(matches!(
|
|
request(¬_final.socket, Message::Approvals(Empty {})),
|
|
Err(AdminError::Protocol(_))
|
|
));
|
|
let other_id = fake_brokerd_frames(|request| {
|
|
vec![Envelope {
|
|
v: PROTOCOL_VERSION,
|
|
id: request.id + 1,
|
|
r#final: true,
|
|
msg: Message::Ok(Empty {}),
|
|
}]
|
|
});
|
|
assert!(matches!(
|
|
request(&other_id.socket, Message::Approvals(Empty {})),
|
|
Err(AdminError::Protocol(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn a_connection_closed_without_an_answer_is_a_frame_error() {
|
|
let fake = fake_brokerd_frames(|_| Vec::new());
|
|
assert!(matches!(
|
|
request(&fake.socket, Message::Approvals(Empty {})),
|
|
Err(AdminError::Frame(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn no_brokerd_is_a_connect_error_that_names_the_socket() {
|
|
let missing = support::temp_socket("nobody-listens.sock");
|
|
let e = request(&missing, Message::Approvals(Empty {})).unwrap_err();
|
|
assert!(matches!(e, AdminError::Connect(_, _)), "{e:?}");
|
|
let message = e.to_string();
|
|
assert!(message.starts_with("cannot reach brokerd at "), "{message}");
|
|
assert!(message.contains(missing.to_str().unwrap()), "{message}");
|
|
}
|
|
|
|
#[test]
|
|
fn list_returns_the_items_and_rejects_any_other_kind() {
|
|
let items = vec![pending(41, "shell", "{}"), pending(44, "read_file", "{}")];
|
|
let fake = brokerd_with(items.clone(), ALLOWED);
|
|
assert_eq!(list(&fake.socket).unwrap(), items);
|
|
assert_eq!(fake.requests(), vec![Message::Approvals(Empty {})]);
|
|
|
|
let wrong = fake_brokerd(|_| Message::Ok(Empty {}));
|
|
assert!(matches!(list(&wrong.socket), Err(AdminError::Protocol(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn reason_names_are_the_wire_names() {
|
|
for reason in [
|
|
DenyReason::NoGrant,
|
|
DenyReason::GrantExpired,
|
|
DenyReason::TaintTooHigh,
|
|
DenyReason::DeniedByGrant,
|
|
DenyReason::ApprovalRefused,
|
|
DenyReason::ApprovalExpired,
|
|
DenyReason::GrantsInvalid,
|
|
DenyReason::AuditUnavailable,
|
|
DenyReason::InvalidArguments,
|
|
DenyReason::StateUnreadable,
|
|
] {
|
|
let wire = serde_json::to_string(&reason).unwrap();
|
|
assert_eq!(format!("\"{}\"", reason_name(reason)), wire);
|
|
}
|
|
}
|
|
|
|
// ---- the block ----
|
|
|
|
fn block(item: &proto::PendingApproval, now: &str) -> String {
|
|
let mut out = Vec::new();
|
|
write_block(&mut out, item, ts(now)).unwrap();
|
|
text(out)
|
|
}
|
|
|
|
#[test]
|
|
fn the_block_is_two_lines_in_this_exact_form() {
|
|
let item = pending(
|
|
41,
|
|
"shell",
|
|
r#"{"command":"rm -rf /home/kyle/scratch/build","cwd":"/home/kyle/scratch"}"#,
|
|
);
|
|
assert_eq!(
|
|
block(&item, "2026-09-18T12:02:00.000Z"),
|
|
concat!(
|
|
"41 2 min ago expires in 13 min session chat-1758… grant shell-scratch taint private\n",
|
|
" shell {\"command\":\"rm -rf /home/kyle/scratch/build\",\"cwd\":\"/home/kyle/scratch\"}\n",
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn times_are_whole_seconds_minutes_or_hours_rounded_down() {
|
|
let item = pending(41, "shell", "{}");
|
|
let first = |now: &str| block(&item, now).lines().next().unwrap().to_string();
|
|
// created 12:00:00, expires 12:15:00
|
|
assert!(first("2026-09-18T12:00:00.000Z").starts_with("41 0 s ago expires in 15 min "));
|
|
assert!(first("2026-09-18T12:00:59.999Z").starts_with("41 59 s ago expires in 14 min "));
|
|
assert!(first("2026-09-18T12:01:00.000Z").starts_with("41 1 min ago expires in 14 min "));
|
|
assert!(first("2026-09-18T12:14:30.000Z").starts_with("41 14 min ago expires in 30 s "));
|
|
// At or after `expires` there is nothing left to wait for.
|
|
assert!(first("2026-09-18T12:15:00.000Z").starts_with("41 15 min ago expired "));
|
|
assert!(first("2026-09-18T14:05:00.000Z").starts_with("41 2 h ago expired "));
|
|
// A clock that is behind the broker's must not underflow.
|
|
assert!(first("2026-09-18T11:59:00.000Z").starts_with("41 0 s ago expires in 16 min "));
|
|
|
|
let mut long = pending(41, "shell", "{}");
|
|
long.expires = ts("2026-09-18T15:30:00.000Z");
|
|
assert!(block(&long, "2026-09-18T12:00:00.000Z").starts_with("41 0 s ago expires in 3 h "));
|
|
}
|
|
|
|
#[test]
|
|
fn a_session_id_longer_than_ten_characters_is_cut_to_nine_and_an_ellipsis() {
|
|
let mut item = pending(41, "shell", "{}");
|
|
for (id, shown) in [
|
|
("s1", "session s1 "),
|
|
("abcdefghij", "session abcdefghij "),
|
|
("abcdefghijk", "session abcdefghi… "),
|
|
] {
|
|
item.session = proto::SessionId::new(id).unwrap();
|
|
let got = block(&item, "2026-09-18T12:00:00.000Z");
|
|
assert!(got.contains(shown), "{id}: {got}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn taint_is_the_wire_name() {
|
|
let mut item = pending(41, "shell", "{}");
|
|
item.taint = proto::DataClass::Secret;
|
|
assert!(block(&item, "2026-09-18T12:00:00.000Z").contains(" taint secret\n"));
|
|
}
|
|
|
|
/// Whatever `brokerd` sends is printed as data: tool, grant and arguments all go through
|
|
/// `escape_json_text`.
|
|
#[test]
|
|
fn nothing_in_the_block_reaches_the_terminal_raw() {
|
|
let rlo = char::from_u32(0x202e).unwrap();
|
|
let zwsp = char::from_u32(0x200b).unwrap();
|
|
let isolate = char::from_u32(0x2066).unwrap();
|
|
let arguments =
|
|
format!("{{\"path\":\"/home/kyle/notes/{rlo}dm.terces{zwsp}{isolate}\x1b[8m\x1b[2J\"}}");
|
|
let mut item = pending(41, "read\x1b[1mfile", &arguments);
|
|
item.grant = "notes\nread".to_string();
|
|
let got = block(&item, "2026-09-18T12:00:00.000Z");
|
|
for c in ['\x1b', rlo, zwsp, isolate] {
|
|
assert!(!got.contains(c), "U+{:04X} in {got:?}", u32::from(c));
|
|
}
|
|
assert_eq!(got.matches('\n').count(), 2, "still two lines: {got:?}");
|
|
assert!(
|
|
got.contains(&format!(
|
|
"/home/kyle/notes/{}dm.terces{}{}{}[8m{}[2J",
|
|
esc(0x202e),
|
|
esc(0x200b),
|
|
esc(0x2066),
|
|
esc(0x1b),
|
|
esc(0x1b)
|
|
)),
|
|
"{got:?}"
|
|
);
|
|
assert!(
|
|
got.contains(&format!(" read{}[1mfile ", esc(0x1b))),
|
|
"{got:?}"
|
|
);
|
|
assert!(
|
|
got.contains(&format!("grant notes{}read ", esc(0x0a))),
|
|
"{got:?}"
|
|
);
|
|
}
|
|
|
|
// ---- the commands ----
|
|
|
|
#[test]
|
|
fn approvals_prints_one_block_per_item_in_the_order_given() {
|
|
let fake = brokerd_with(
|
|
vec![
|
|
pending(41, "shell", r#"{"command":"ls"}"#),
|
|
pending(44, "read_file", r#"{"path":"/home/kyle/notes/a.md"}"#),
|
|
],
|
|
ALLOWED,
|
|
);
|
|
let mut out = Vec::new();
|
|
let ok = cmd_approvals(&fake.socket, ts("2026-09-18T12:02:00.000Z"), &mut out).unwrap();
|
|
assert!(ok);
|
|
let got = text(out);
|
|
let lines: Vec<&str> = got.lines().collect();
|
|
assert_eq!(lines.len(), 4, "{got}");
|
|
assert!(lines[0].starts_with("41 2 min ago "), "{got}");
|
|
assert_eq!(lines[1], r#" shell {"command":"ls"}"#);
|
|
assert!(lines[2].starts_with("44 2 min ago "), "{got}");
|
|
assert_eq!(
|
|
lines[3],
|
|
r#" read_file {"path":"/home/kyle/notes/a.md"}"#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn approvals_with_nothing_pending_says_so() {
|
|
let fake = brokerd_with(Vec::new(), ALLOWED);
|
|
let mut out = Vec::new();
|
|
assert!(cmd_approvals(&fake.socket, ts("2026-09-18T12:00:00.000Z"), &mut out).unwrap());
|
|
assert_eq!(text(out), "no pending approvals\n");
|
|
}
|
|
|
|
#[test]
|
|
fn approve_reports_the_re_decision() {
|
|
// `ask` and `allowed` both let the call run; only a denial stops it.
|
|
for (outcome, line, ok) in [
|
|
(DecisionRecord::Allowed {}, "approved 41: runs\n", true),
|
|
(DecisionRecord::Ask {}, "approved 41: runs\n", true),
|
|
(
|
|
DecisionRecord::Denied {
|
|
reason: DenyReason::NoGrant,
|
|
},
|
|
"approved 41: denied (no_grant)\n",
|
|
false,
|
|
),
|
|
(
|
|
DecisionRecord::Denied {
|
|
reason: DenyReason::TaintTooHigh,
|
|
},
|
|
"approved 41: denied (taint_too_high)\n",
|
|
false,
|
|
),
|
|
(
|
|
DecisionRecord::Denied {
|
|
reason: DenyReason::AuditUnavailable,
|
|
},
|
|
"approved 41: denied (audit_unavailable)\n",
|
|
false,
|
|
),
|
|
] {
|
|
let fake = brokerd_with(vec![pending(41, "shell", "{}")], outcome);
|
|
let mut out = Vec::new();
|
|
assert_eq!(cmd_approve(&fake.socket, 41, &mut out).unwrap(), ok);
|
|
assert_eq!(text(out), line);
|
|
assert_eq!(
|
|
fake.requests(),
|
|
vec![Message::Approve(Approve { approval: 41 })]
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn refuse_sends_the_reason_when_there_is_one() {
|
|
for reason in [None, Some("not on a Friday")] {
|
|
let fake = brokerd_with(vec![pending(41, "shell", "{}")], ALLOWED);
|
|
let mut out = Vec::new();
|
|
assert!(cmd_refuse(&fake.socket, 41, reason, &mut out).unwrap());
|
|
assert_eq!(text(out), "refused 41\n");
|
|
assert_eq!(
|
|
fake.requests(),
|
|
vec![Message::Refuse(Refuse {
|
|
approval: 41,
|
|
reason: reason.map(str::to_string)
|
|
})]
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_or_answered_id_is_reported_the_same_way_by_both() {
|
|
let fake = brokerd_with(vec![pending(41, "shell", "{}")], ALLOWED);
|
|
let want = "99: no such approval (already answered or expired)\n";
|
|
let mut out = Vec::new();
|
|
assert!(!cmd_approve(&fake.socket, 99, &mut out).unwrap());
|
|
assert_eq!(text(out), want);
|
|
let mut out = Vec::new();
|
|
assert!(!cmd_refuse(&fake.socket, 99, None, &mut out).unwrap());
|
|
assert_eq!(text(out), want);
|
|
}
|
|
|
|
/// Every exit of every command: any other error frame is an error, and so is an answer of the
|
|
/// wrong kind. Nothing is printed for them.
|
|
#[test]
|
|
fn other_errors_and_wrong_kinds_are_errors_for_every_command() {
|
|
let refused = fake_brokerd(|_| wire_error(ErrorCode::Internal, "boom"));
|
|
let wrong = fake_brokerd(|_| {
|
|
Message::GrantsReport(GrantsReport {
|
|
problems: Vec::new(),
|
|
})
|
|
});
|
|
let wrong_for_grants = fake_brokerd(|_| Message::Ok(Empty {}));
|
|
let now = ts("2026-09-18T12:00:00.000Z");
|
|
|
|
let mut out = Vec::new();
|
|
assert!(matches!(
|
|
cmd_approvals(&refused.socket, now, &mut out),
|
|
Err(AdminError::Refused(_))
|
|
));
|
|
assert!(matches!(
|
|
cmd_approve(&refused.socket, 41, &mut out),
|
|
Err(AdminError::Refused(_))
|
|
));
|
|
assert!(matches!(
|
|
cmd_refuse(&refused.socket, 41, None, &mut out),
|
|
Err(AdminError::Refused(_))
|
|
));
|
|
assert!(matches!(
|
|
cmd_grants_check(&refused.socket, &mut out),
|
|
Err(AdminError::Refused(_))
|
|
));
|
|
assert!(matches!(
|
|
cmd_approvals(&wrong.socket, now, &mut out),
|
|
Err(AdminError::Protocol(_))
|
|
));
|
|
assert!(matches!(
|
|
cmd_approve(&wrong.socket, 41, &mut out),
|
|
Err(AdminError::Protocol(_))
|
|
));
|
|
assert!(matches!(
|
|
cmd_refuse(&wrong.socket, 41, None, &mut out),
|
|
Err(AdminError::Protocol(_))
|
|
));
|
|
assert!(matches!(
|
|
cmd_grants_check(&wrong_for_grants.socket, &mut out),
|
|
Err(AdminError::Protocol(_))
|
|
));
|
|
assert_eq!(text(out), "", "an error prints nothing on the output");
|
|
}
|
|
|
|
#[test]
|
|
fn grants_check_prints_ok_or_every_problem() {
|
|
let fine = fake_brokerd(|_| {
|
|
Message::GrantsReport(GrantsReport {
|
|
problems: Vec::new(),
|
|
})
|
|
});
|
|
let mut out = Vec::new();
|
|
assert!(cmd_grants_check(&fine.socket, &mut out).unwrap());
|
|
assert_eq!(text(out), "grants: ok\n");
|
|
assert_eq!(fine.requests(), vec![Message::CheckGrants(Empty {})]);
|
|
|
|
let broken = fake_brokerd(|_| {
|
|
Message::GrantsReport(GrantsReport {
|
|
problems: vec![
|
|
GrantProblem {
|
|
file: "notes-read.toml".to_string(),
|
|
line: Some(3),
|
|
problem: "unknown field `mod`".to_string(),
|
|
},
|
|
GrantProblem {
|
|
file: "Bad Name.toml".to_string(),
|
|
line: None,
|
|
problem: "the file name is not a valid grant id".to_string(),
|
|
},
|
|
GrantProblem {
|
|
file: "x.toml".to_string(),
|
|
line: Some(1),
|
|
problem: "two\nlines".to_string(),
|
|
},
|
|
],
|
|
})
|
|
});
|
|
let mut out = Vec::new();
|
|
assert!(!cmd_grants_check(&broken.socket, &mut out).unwrap());
|
|
assert_eq!(
|
|
text(out),
|
|
format!(
|
|
"notes-read.toml:3: unknown field `mod`\n\
|
|
Bad Name.toml: the file name is not a valid grant id\n\
|
|
x.toml:1: two{}lines\n",
|
|
esc(0x0a)
|
|
),
|
|
"every problem, one line each; file and problem go through escape_json_text"
|
|
);
|
|
}
|
|
|
|
// ---- the binary ----
|
|
|
|
fn bxctl(args: &[&str], socket: &std::path::Path) -> std::process::Output {
|
|
Command::new(env!("CARGO_BIN_EXE_bxctl"))
|
|
.args(args)
|
|
.arg("--admin-socket")
|
|
.arg(socket)
|
|
.output()
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn the_binary_prints_on_stdout_and_sets_the_exit_status() {
|
|
let fake = brokerd_with(
|
|
vec![pending(41, "shell", r#"{"command":"ls"}"#)],
|
|
DecisionRecord::Denied {
|
|
reason: DenyReason::DeniedByGrant,
|
|
},
|
|
);
|
|
let output = bxctl(&["approvals"], &fake.socket);
|
|
assert_eq!(output.status.code(), Some(0));
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.ends_with(" shell {\"command\":\"ls\"}\n"),
|
|
"{stdout}"
|
|
);
|
|
|
|
let output = bxctl(&["approve", "41"], &fake.socket);
|
|
assert_eq!(output.status.code(), Some(1));
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&output.stdout),
|
|
"approved 41: denied (denied_by_grant)\n"
|
|
);
|
|
|
|
let output = bxctl(&["refuse", "41", "--reason", "no"], &fake.socket);
|
|
assert_eq!(output.status.code(), Some(0));
|
|
assert_eq!(String::from_utf8_lossy(&output.stdout), "refused 41\n");
|
|
|
|
let output = bxctl(&["refuse", "7"], &fake.socket);
|
|
assert_eq!(output.status.code(), Some(1));
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&output.stdout),
|
|
"7: no such approval (already answered or expired)\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_binary_reports_an_error_on_stderr_with_status_1() {
|
|
let fake = fake_brokerd(|_| wire_error(ErrorCode::Internal, "boom"));
|
|
let output = bxctl(&["grants", "check"], &fake.socket);
|
|
assert_eq!(output.status.code(), Some(1));
|
|
assert_eq!(String::from_utf8_lossy(&output.stdout), "");
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&output.stderr),
|
|
"bxctl: internal: boom\n"
|
|
);
|
|
}
|