Add the admin messages, approval ids as numbers, and two turn events
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
//! Tests for the admin messages of `admin.sock` and the two new error codes, against byte-exact
|
||||
//! fixtures. Do not edit these or the fixtures.
|
||||
|
||||
use proto::{
|
||||
ApprovalList, Approve, ApproveResult, CallId, DataClass, DecisionRecord, DenyReason, Empty,
|
||||
Envelope, ErrorCode, GrantProblem, GrantsReport, Message, PendingApproval, Refuse, SessionId,
|
||||
Timestamp, WireError,
|
||||
};
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
let path = format!("{}/tests/fixtures/wire/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||
text.trim_end_matches('\n').to_string()
|
||||
}
|
||||
|
||||
/// The fixture must decode to `want`, and `want` must encode to exactly the fixture's bytes.
|
||||
fn check(name: &str, id: u64, msg: Message) {
|
||||
let want = Envelope {
|
||||
v: 1,
|
||||
id,
|
||||
r#final: true,
|
||||
msg,
|
||||
};
|
||||
let text = fixture(name);
|
||||
let got: Envelope = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(got, want, "{name}: decoded value");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&want).unwrap(),
|
||||
text,
|
||||
"{name}: encoded bytes"
|
||||
);
|
||||
}
|
||||
|
||||
fn pending() -> PendingApproval {
|
||||
PendingApproval {
|
||||
approval: 41,
|
||||
session: SessionId::new("chat-1789700000-42").unwrap(),
|
||||
call: CallId(3),
|
||||
tool: "shell".to_string(),
|
||||
arguments: r#"{"command":"rm -rf /home/kyle/scratch/build","cwd":"/home/kyle/scratch"}"#
|
||||
.to_string(),
|
||||
grant: "shell-scratch".to_string(),
|
||||
taint: DataClass::Private,
|
||||
created: Timestamp::parse("2026-09-18T08:05:00.000Z").unwrap(),
|
||||
expires: Timestamp::parse("2026-09-18T08:20:00.000Z").unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_three_requests_with_an_empty_body() {
|
||||
check("approvals.json", 5, Message::Approvals(Empty {}));
|
||||
check("check_grants.json", 8, Message::CheckGrants(Empty {}));
|
||||
check("ok.json", 7, Message::Ok(Empty {}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_list() {
|
||||
let list = ApprovalList {
|
||||
items: vec![pending()],
|
||||
};
|
||||
check("approval_list.json", 5, Message::ApprovalList(list));
|
||||
let empty = ApprovalList { items: Vec::new() };
|
||||
check("approval_list_empty.json", 5, Message::ApprovalList(empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approve_and_its_result() {
|
||||
check(
|
||||
"approve.json",
|
||||
6,
|
||||
Message::Approve(Approve { approval: 41 }),
|
||||
);
|
||||
check(
|
||||
"approve_result_allowed.json",
|
||||
6,
|
||||
Message::ApproveResult(ApproveResult {
|
||||
outcome: DecisionRecord::Allowed {},
|
||||
}),
|
||||
);
|
||||
check(
|
||||
"approve_result_denied.json",
|
||||
6,
|
||||
Message::ApproveResult(ApproveResult {
|
||||
outcome: DecisionRecord::Denied {
|
||||
reason: DenyReason::NoGrant,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuse_with_and_without_a_reason() {
|
||||
check(
|
||||
"refuse.json",
|
||||
7,
|
||||
Message::Refuse(Refuse {
|
||||
approval: 41,
|
||||
reason: Some("not while I am away".to_string()),
|
||||
}),
|
||||
);
|
||||
check(
|
||||
"refuse_no_reason.json",
|
||||
7,
|
||||
Message::Refuse(Refuse {
|
||||
approval: 41,
|
||||
reason: None,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grants_report() {
|
||||
let report = GrantsReport {
|
||||
problems: vec![
|
||||
GrantProblem {
|
||||
file: "notes-read.toml".to_string(),
|
||||
line: Some(3),
|
||||
problem: "unknown field `mdoe`".to_string(),
|
||||
},
|
||||
GrantProblem {
|
||||
file: "Bad_Name.toml".to_string(),
|
||||
line: None,
|
||||
problem: "the file name is not a valid grant id".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
check("grants_report.json", 8, Message::GrantsReport(report));
|
||||
let ok = GrantsReport {
|
||||
problems: Vec::new(),
|
||||
};
|
||||
check("grants_report_ok.json", 8, Message::GrantsReport(ok));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_new_error_codes() {
|
||||
check(
|
||||
"error_forbidden.json",
|
||||
9,
|
||||
Message::Error(WireError {
|
||||
code: ErrorCode::Forbidden,
|
||||
detail: "approve is not accepted on broker.sock".to_string(),
|
||||
}),
|
||||
);
|
||||
check(
|
||||
"error_no_such_approval.json",
|
||||
6,
|
||||
Message::Error(WireError {
|
||||
code: ErrorCode::NoSuchApproval,
|
||||
detail: "41".to_string(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty body is an object with no keys: not `null`, not a missing body, not an object with a
|
||||
/// key in it.
|
||||
#[test]
|
||||
fn an_empty_body_must_be_an_empty_object() {
|
||||
let good = fixture("approvals.json");
|
||||
assert!(serde_json::from_str::<Envelope>(&good).is_ok());
|
||||
let bad = [
|
||||
good.replacen("\"body\":{}", "\"body\":null", 1),
|
||||
good.replacen(",\"body\":{}", "", 1),
|
||||
good.replacen("\"body\":{}", "\"body\":{\"all\":true}", 1),
|
||||
];
|
||||
for text in bad {
|
||||
assert_ne!(text, good);
|
||||
assert!(
|
||||
serde_json::from_str::<Envelope>(&text).is_err(),
|
||||
"accepted {text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `reason` and `line` may be null but may not be left out: every field is always written, so a
|
||||
/// reader never has to guess what a missing one means.
|
||||
#[test]
|
||||
fn optional_fields_are_null_not_absent() {
|
||||
let refuse = fixture("refuse_no_reason.json");
|
||||
let cut = refuse.replacen(",\"reason\":null", "", 1);
|
||||
assert_ne!(cut, refuse);
|
||||
assert!(
|
||||
serde_json::from_str::<Envelope>(&cut).is_ok(),
|
||||
"serde reads a missing Option as None; this documents it"
|
||||
);
|
||||
assert!(
|
||||
serde_json::to_string(&Refuse {
|
||||
approval: 1,
|
||||
reason: None
|
||||
})
|
||||
.unwrap()
|
||||
.contains("\"reason\":null")
|
||||
);
|
||||
assert!(
|
||||
serde_json::to_string(&GrantProblem {
|
||||
file: "a.toml".to_string(),
|
||||
line: None,
|
||||
problem: "x".to_string()
|
||||
})
|
||||
.unwrap()
|
||||
.contains("\"line\":null")
|
||||
);
|
||||
}
|
||||
|
||||
/// An outcome is strict too. serde does not apply `deny_unknown_fields` to unit variants such as
|
||||
/// `allowed`, so `DecisionRecord` must not rely on the derive for it.
|
||||
#[test]
|
||||
fn an_outcome_rejects_unknown_and_misplaced_fields() {
|
||||
for good in [
|
||||
r#"{"outcome":"allowed"}"#,
|
||||
r#"{"outcome":"ask"}"#,
|
||||
r#"{"outcome":"denied","reason":"no_grant"}"#,
|
||||
] {
|
||||
let value: DecisionRecord = serde_json::from_str(good).unwrap();
|
||||
assert_eq!(serde_json::to_string(&value).unwrap(), good);
|
||||
}
|
||||
for bad in [
|
||||
r#"{"outcome":"allowed","zz":1}"#,
|
||||
r#"{"outcome":"ask","zz":1}"#,
|
||||
r#"{"outcome":"denied","reason":"no_grant","zz":1}"#,
|
||||
r#"{"outcome":"allowed","reason":"no_grant"}"#,
|
||||
r#"{"outcome":"ask","reason":null,"zz":1}"#,
|
||||
r#"{"outcome":"allowed","reason":null}"#,
|
||||
r#"{"outcome":"denied"}"#,
|
||||
r#"{"outcome":"approved"}"#,
|
||||
r#"{"reason":"no_grant"}"#,
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_str::<DecisionRecord>(bad).is_err(),
|
||||
"accepted {bad}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_approvals_reject_bad_values() {
|
||||
let good = fixture("approval_list.json");
|
||||
assert!(serde_json::from_str::<Envelope>(&good).is_ok());
|
||||
let bad = [
|
||||
// an approval id is a number
|
||||
good.replacen("\"approval\":41", "\"approval\":\"41\"", 1),
|
||||
// the session id is validated
|
||||
good.replacen("chat-1789700000-42", "../etc", 1),
|
||||
// the taint is one of the three classes
|
||||
good.replacen("\"taint\":\"private\"", "\"taint\":\"internal\"", 1),
|
||||
// a field is missing
|
||||
good.replacen("\"grant\":\"shell-scratch\",", "", 1),
|
||||
// a field nobody defined
|
||||
good.replacen("\"grant\":", "\"note\":1,\"grant\":", 1),
|
||||
];
|
||||
for text in bad {
|
||||
assert_ne!(text, good);
|
||||
assert!(
|
||||
serde_json::from_str::<Envelope>(&text).is_err(),
|
||||
"accepted {text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user