The agent token is deliberately barred from /v1/ingest: the events are the inbound channel, and the model must not feed it itself. But the same bar closes the door on every honest integration too — a sidecar poller with events to hand in had to hold the human token, which is wider than its job. This adds the missing class: a third bearer, the ingest token, answering the event door and nothing else. `ingest_allowed` is the whole credential in one function — one route row, `POST /v1/ingest` — symmetric with `agent_allowed` and suite-covered the same way: the mock and the daemon's real router both answer the new checks (a push succeeds with the contract header; a watch read and a consent answer are 403). The middleware classifier gains the third arm, boot refuses an ingest token equal to either other credential — one value playing two roles silently widens the scoped one — and the daemon provisions it to tool shells as `RHEMA_INGEST_TOKEN`, so a tool that only pushes events never sees the wider agent token at all. The door is the first half of the extensions story (rhema-tools spec §8): a self-timed sidecar is now a tier-1 tool with a credential narrow enough to hold.
59 lines
2.0 KiB
Rust
59 lines
2.0 KiB
Rust
//! The contract's own gate: the suite passes against the mock router.
|
|
//! The daemon's real router runs the same suite in its test tree —
|
|
//! same results is the definition of implementing the contract.
|
|
|
|
use rhema_tools_contract::{CallRequest, MockClaw, conformance};
|
|
|
|
#[tokio::test]
|
|
async fn the_suite_passes_against_the_mock_router() {
|
|
let mock = MockClaw::new(
|
|
Some("human-token"),
|
|
Some("agent-token"),
|
|
Some("ingest-token"),
|
|
);
|
|
let result =
|
|
conformance(|request: CallRequest| async { Ok(mock.call(request).await.unwrap()) }).await;
|
|
assert_eq!(result, Ok(()), "the mock router speaks the contract");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_open_mock_fails_the_suite() {
|
|
// No tokens: the agent-token guarantees the suite exists to check
|
|
// are gone. The suite must say so, not sail through.
|
|
let mock = MockClaw::new(None, None, None);
|
|
let result =
|
|
conformance(|request: CallRequest| async { Ok(mock.call(request).await.unwrap()) }).await;
|
|
assert!(result.is_err(), "an open surface cannot pass the suite");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_agent_allowlist_reads_the_documented_routes() {
|
|
assert!(rhema_tools_contract::agent_allowed("GET", "/v1/watches"));
|
|
assert!(rhema_tools_contract::agent_allowed(
|
|
"DELETE",
|
|
"/v1/watches/github/acme/widgets"
|
|
));
|
|
assert!(rhema_tools_contract::agent_allowed(
|
|
"POST",
|
|
"/v1/notifications"
|
|
));
|
|
assert!(rhema_tools_contract::agent_allowed(
|
|
"DELETE",
|
|
"/v1/tasks/12"
|
|
));
|
|
assert!(!rhema_tools_contract::agent_allowed("POST", "/v1/ingest"));
|
|
assert!(!rhema_tools_contract::agent_allowed(
|
|
"POST",
|
|
"/v1/consents/c-1"
|
|
));
|
|
assert!(!rhema_tools_contract::agent_allowed("GET", "/v1/consents"));
|
|
assert!(!rhema_tools_contract::agent_allowed(
|
|
"DELETE",
|
|
"/v1/phones/p1"
|
|
));
|
|
assert!(
|
|
!rhema_tools_contract::agent_allowed("GET", "/v1/watches/github/acme/widgets"),
|
|
"only DELETE reaches subpaths of /v1/watches"
|
|
);
|
|
}
|